From 69a310c78f5ceaf4eab7dfee98f187d38211c9ba Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Jun 2026 10:49:09 +0600 Subject: [PATCH 001/132] Release 5.1.2+45 (#3049) * chore: fix white screen issue * fix(newpipe): fallback to muxed streams if no audio stream is available * fix: dismiss search dropdown and keyboard on submission Co-authored-by: Tobse <1190109+TobseF@users.noreply.github.com> * fix: custom image helper null exception * chore: generate changelog and bump version for release --------- Co-authored-by: tomasalias Co-authored-by: Akshat-104 Co-authored-by: Tobse <1190109+TobseF@users.noreply.github.com> --- .fvmrc | 3 +- .vscode/settings.json | 3 +- CHANGELOG.md | 1910 ++++++++++++----- lib/models/metadata/image.dart | 3 +- lib/pages/search/search.dart | 95 +- .../youtube_engine/newpipe_engine.dart | 36 + pubspec.lock | 10 +- pubspec.yaml | 8 +- 8 files changed, 1436 insertions(+), 632 deletions(-) diff --git a/.fvmrc b/.fvmrc index cf986e39..2bb4682a 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,4 +1,3 @@ { - "flutter": "3.35.2", - "flavors": {} + "flutter": "3.35.2" } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 69c80bb3..6cfcec03 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,5 +30,6 @@ "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", "*.dart": "${capture}.g.dart,${capture}.freezed.dart" }, - "dart.flutterSdkPath": ".fvm/versions/3.35.2" + "dart.flutterSdkPath": ".fvm/versions/3.35.2", + "makefile.configureOnOpen": false } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8025a127..70cf335a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [5.1.2](https://github.com/KRTirtho/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) + +### Bug Fixes + +- **newpipe**: Fallback to muxed streams if no audio stream is available +- Dismiss search dropdown and keyboard on submission +- Custom image helper null exception + ## [5.1.1](https://github.com/KRTirtho/spotube/compare/v5.1.0...v5.1.1) (2026-02-24) ### Bug Fixes @@ -15,12 +23,14 @@ - **playback**: Add dab music source - **playback**: Add uncompressed flac playback support - Add plugin audio source models and api service -- **plugins**: Filter plugins by abilities in plugins page and show abilities as badge +- **plugins**: Filter plugins by abilities in plugins page and show abilities as + badge - Add setting default audio source support - Move away from track source query and preferences audio quality and codec - Add NewPipe support for desktop platforms - Add default plugin loading capability -- **queue**: Add multi-select and bulk actions to queue ([#2839](https://github.com/KRTirtho/spotube/issues/2839)) +- **queue**: Add multi-select and bulk actions to queue + ([#2839](https://github.com/KRTirtho/spotube/issues/2839)) - **android**: Add 16KB page size support ### Bug Fixes @@ -35,14 +45,17 @@ ### Features -- Add ISRC track search for YouTube ([#2594](https://github.com/KRTirtho/spotube/issues/2594)) -- Add new icons #2676 by @alexio-dev ([#2678](https://github.com/KRTirtho/spotube/issues/2678)) +- Add ISRC track search for YouTube + ([#2594](https://github.com/KRTirtho/spotube/issues/2594)) +- Add new icons #2676 by @alexio-dev + ([#2678](https://github.com/KRTirtho/spotube/issues/2678)) - Add connect confirmation dialog - Add metadata api service and models - **metadata-plugin**: Add pagination support, feed and playlist CRUD endpoints - **metadata-plugin**: Add local storage api - Add webview, totp and setInterval apis for plugins -- Enhance local storage and webview APIs with improved error handling and resource management +- Enhance local storage and webview APIs with improved error handling and + resource management - **metadata_plugin**: Add logout method - Update plugin configuration with more fields - Implement metadata plugins based on hetu @@ -79,7 +92,8 @@ - Local track not working and images of local not showing up - Local playback not working for tracks with special # (hashtag) characters - Inaccessible streaming url causing rapid skips -- **yt**: Fallback to different search result if all streaming url is inaccessible +- **yt**: Fallback to different search result if all streaming url is + inaccessible - **playback**: Skip network requests if cached file already exists - Yt-dlp playback not working and add partial support for HLS streaming - Windows webview2 environment permission issue @@ -90,9 +104,12 @@ ### Translation -- Add Traditional Chinese translation ([#2762](https://github.com/KRTirtho/spotube/issues/2762)) -- Fix Japanese translations ([#2732](https://github.com/KRTirtho/spotube/issues/2732)) -- Correction of the dutch language ([#1306](https://github.com/KRTirtho/spotube/issues/1306)) +- Add Traditional Chinese translation + ([#2762](https://github.com/KRTirtho/spotube/issues/2762)) +- Fix Japanese translations + ([#2732](https://github.com/KRTirtho/spotube/issues/2732)) +- Correction of the dutch language + ([#1306](https://github.com/KRTirtho/spotube/issues/1306)) ## [4.0.2](https://github.com/krtirtho/spotube/compare/v4.0.1...v4.0.2) (2025-03-16) @@ -107,8 +124,10 @@ - **android**: navigation overlaying in app navigation - add to playlist not working in smaller screen devices - language picker search broken -- **generate_playlist**: create playlist not adding tracks nor navigating to playlist page -- **desktop**: double titlebar in local library folders and massive space in overlay player +- **generate_playlist**: create playlist not adding tracks nor navigating to + playlist page +- **desktop**: double titlebar in local library folders and massive space in + overlay player - lastfm form broken in other locales #2447 - spotify login broken due to new totp requirement #2494 - spotify authentication 429 errors @@ -135,7 +154,8 @@ - youtube source taking too long to buffer - youtube tracks keeps skipping despite being matched correctly - follow artist not working #2057 -- youtube_explode_dart failing for many videos due to youtube ios client visitor data change +- youtube_explode_dart failing for many videos due to youtube ios client visitor + data change - piped api not working ### Features @@ -156,8 +176,10 @@ ### Bug Fixes - UI glitch when loading more user artists and albums -- selecting an Alternative Track Source removes the current song from the queue #2039 -- **mobile**: ensure audio session is activated when playback is resumed after interruption #2092 +- selecting an Alternative Track Source removes the current song from the queue + #2039 +- **mobile**: ensure audio session is activated when playback is resumed after + interruption #2092 ### Features @@ -193,11 +215,13 @@ - **translations**: correct some basque incorrect translations (#1815) - **lyrics**: LRCLIB lyrics should be usable without logging in #1803 - playlist displaying descriptions unescaped html #1784 -- **android**: pressing back while the player is open doesn't take to previous page +- **android**: pressing back while the player is open doesn't take to previous + page - handle dublicated items in playback queue correctly #1852 - **desktop**: scrollbar overlapping with more options of tracks and playlists - **discord**: stop discord rpc from try update presence when not connected -- **stats**: minutes page shows plays and streams page shows minutes which should be the opposite #1880 +- **stats**: minutes page shows plays and streams page shows minutes which + should be the opposite #1880 - **android**: clears queue upon swiping away notification - **player**: shuffle button state resets after closing page #1657 - getting started page login page exception #1800 @@ -248,695 +272,1430 @@ ### Bug Fixes -- alternative sources not showing up for SongLink matched results ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) -- **android:** Media Controls not working above Android 14 [#1561](https://github.com/krtirtho/spotube/issues/1561) ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) -- browse anonymously button takes to wrong route ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) -- **desktop:** titlebar drag to move not working ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) -- **desktop:** window is not centered ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) -- **ios:** download not working [#1575](https://github.com/krtirtho/spotube/issues/1575) ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) -- **linux:** application window not visible after launch ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) -- local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) -- use weak match for Jiosaavn fallback to improve matching ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) -- **windows:** media controls not showing up [#1542](https://github.com/krtirtho/spotube/issues/1542) ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) -- **windows:** revert Flutter version to 3.19.6 to avoid distortion [#1553](https://github.com/krtirtho/spotube/issues/1553) ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) +- alternative sources not showing up for SongLink matched results + ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) +- **android:** Media Controls not working above Android 14 + [#1561](https://github.com/krtirtho/spotube/issues/1561) + ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) +- browse anonymously button takes to wrong route + ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) +- **desktop:** titlebar drag to move not working + ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) +- **desktop:** window is not centered + ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) +- **ios:** download not working + [#1575](https://github.com/krtirtho/spotube/issues/1575) + ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) +- **linux:** application window not visible after launch + ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) +- local track not showing up in queue + ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +- use weak match for Jiosaavn fallback to improve matching + ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) +- **windows:** media controls not showing up + [#1542](https://github.com/krtirtho/spotube/issues/1542) + ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) +- **windows:** revert Flutter version to 3.19.6 to avoid distortion + [#1553](https://github.com/krtirtho/spotube/issues/1553) + ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) ## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) ### Features -- local library folder cards ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) -- Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) -- personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) -- play initially available tracks of playlist/album immediately and fetch rest in background [#670](https://github.com/krtirtho/spotube/issues/670) ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) -- **player:** add volume slider floating label showing percentage ([#1445](https://github.com/krtirtho/spotube/issues/1445)) ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -- **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) -- **translations:** add Finnish translations ([#1449](https://github.com/krtirtho/spotube/issues/1449)) ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -- **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) -- **translations:** add Indonesian translation ([#1426](https://github.com/krtirtho/spotube/issues/1426)) ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -- **translations:** Improve tr locales ([#1419](https://github.com/krtirtho/spotube/issues/1419)) ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -- upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) +- local library folder cards + ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) +- Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) + ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) +- personalized stats based on local music history + ([#1522](https://github.com/krtirtho/spotube/issues/1522)) + ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) +- play initially available tracks of playlist/album immediately and fetch rest + in background [#670](https://github.com/krtirtho/spotube/issues/670) + ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) +- **player:** add volume slider floating label showing percentage + ([#1445](https://github.com/krtirtho/spotube/issues/1445)) + ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), + closes [#1310](https://github.com/krtirtho/spotube/issues/1310) + [#1311](https://github.com/krtirtho/spotube/issues/1311) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1171](https://github.com/krtirtho/spotube/issues/1171) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1082](https://github.com/krtirtho/spotube/issues/1082) +- **translations:** add Basque translation + ([#1493](https://github.com/krtirtho/spotube/issues/1493)) + ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) +- **translations:** add Finnish translations + ([#1449](https://github.com/krtirtho/spotube/issues/1449)) + ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), + closes [#1310](https://github.com/krtirtho/spotube/issues/1310) + [#1311](https://github.com/krtirtho/spotube/issues/1311) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1171](https://github.com/krtirtho/spotube/issues/1171) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1082](https://github.com/krtirtho/spotube/issues/1082) +- **translations:** add georgian language + ([#1450](https://github.com/krtirtho/spotube/issues/1450)) + ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) +- **translations:** add Indonesian translation + ([#1426](https://github.com/krtirtho/spotube/issues/1426)) + ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), + closes [#1310](https://github.com/krtirtho/spotube/issues/1310) + [#1311](https://github.com/krtirtho/spotube/issues/1311) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1171](https://github.com/krtirtho/spotube/issues/1171) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1082](https://github.com/krtirtho/spotube/issues/1082) +- **translations:** Improve tr locales + ([#1419](https://github.com/krtirtho/spotube/issues/1419)) + ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), + closes [#1310](https://github.com/krtirtho/spotube/issues/1310) + [#1311](https://github.com/krtirtho/spotube/issues/1311) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1171](https://github.com/krtirtho/spotube/issues/1171) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1082](https://github.com/krtirtho/spotube/issues/1082) +- upgrade to Flutter 3.22.0 + ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) ### Bug Fixes -- fallback to LRCLIB when lyrics line less than 6 lines [#1461](https://github.com/krtirtho/spotube/issues/1461) ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) -- **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) -- local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) -- **macos:** Logs directory not created by default [#1353](https://github.com/krtirtho/spotube/issues/1353) ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) -- **playback:** skipping tracks with unplayable sources instead of falling back [#1492](https://github.com/krtirtho/spotube/issues/1492) ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) -- **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) -- some text are garbled in different parts of the app [#1463](https://github.com/krtirtho/spotube/issues/1463) [#1505](https://github.com/krtirtho/spotube/issues/1505) ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) -- spotify friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/krtirtho/spotube/issues/1410) ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) -- **updater:** dead link ([#1408](https://github.com/krtirtho/spotube/issues/1408)) ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -- windows SSL Certificate error breaking login [#905](https://github.com/krtirtho/spotube/issues/905) ([#1474](https://github.com/krtirtho/spotube/issues/1474)) ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), closes [#1468](https://github.com/krtirtho/spotube/issues/1468) -- **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) +- fallback to LRCLIB when lyrics line less than 6 lines + [#1461](https://github.com/krtirtho/spotube/issues/1461) + ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) +- **linux:** tray icon not showing + [#541](https://github.com/krtirtho/spotube/issues/541) + ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) +- local track not showing up in queue + ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +- **macos:** Logs directory not created by default + [#1353](https://github.com/krtirtho/spotube/issues/1353) + ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) +- **playback:** skipping tracks with unplayable sources instead of falling back + [#1492](https://github.com/krtirtho/spotube/issues/1492) + ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) +- **search:** load more button not working + [#1417](https://github.com/krtirtho/spotube/issues/1417) + ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) +- some text are garbled in different parts of the app + [#1463](https://github.com/krtirtho/spotube/issues/1463) + [#1505](https://github.com/krtirtho/spotube/issues/1505) + ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) +- spotify friends and user profile icon (mobile) showing when not authenticated + [#1410](https://github.com/krtirtho/spotube/issues/1410) + ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) +- **updater:** dead link + ([#1408](https://github.com/krtirtho/spotube/issues/1408)) + ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), + closes [#1310](https://github.com/krtirtho/spotube/issues/1310) + [#1311](https://github.com/krtirtho/spotube/issues/1311) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1171](https://github.com/krtirtho/spotube/issues/1171) + [#1082](https://github.com/krtirtho/spotube/issues/1082) + [#1082](https://github.com/krtirtho/spotube/issues/1082) +- windows SSL Certificate error breaking login + [#905](https://github.com/krtirtho/spotube/issues/905) + ([#1474](https://github.com/krtirtho/spotube/issues/1474)) + ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), + closes [#1468](https://github.com/krtirtho/spotube/issues/1468) +- **windows:** installer tries to install in current directory + ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) ## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) ### Features -- add Spotify homepage personalized recommendations ([#1402](https://github.com/krtirtho/spotube/issues/1402)) ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) -- add user profile page ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) -- **android:** Filter Device To Force High Frame Rate ([#880](https://github.com/krtirtho/spotube/issues/880)) ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) -- improved caching based on riverpod ([#1343](https://github.com/krtirtho/spotube/issues/1343)) ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) -- LAN connect a.k.a control remote Spotube playback and local output device selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) -- **lyrics:** add LRCLIB lyrics provider as fallback ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) -- search history support [#1236](https://github.com/krtirtho/spotube/issues/1236) ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) -- **translations:** Add Czech translation ([#1401](https://github.com/krtirtho/spotube/issues/1401)) ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) -- **translations:** add Thai Language ([#1319](https://github.com/krtirtho/spotube/issues/1319)) ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) +- add Spotify homepage personalized recommendations + ([#1402](https://github.com/krtirtho/spotube/issues/1402)) + ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) +- add user profile page + ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) +- **android:** Filter Device To Force High Frame Rate + ([#880](https://github.com/krtirtho/spotube/issues/880)) + ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) +- improved caching based on riverpod + ([#1343](https://github.com/krtirtho/spotube/issues/1343)) + ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) +- LAN connect a.k.a control remote Spotube playback and local output device + selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) + ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) +- **lyrics:** add LRCLIB lyrics provider as fallback + ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) +- search history support + [#1236](https://github.com/krtirtho/spotube/issues/1236) + ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) +- **translations:** Add Czech translation + ([#1401](https://github.com/krtirtho/spotube/issues/1401)) + ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) +- **translations:** add Thai Language + ([#1319](https://github.com/krtirtho/spotube/issues/1319)) + ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), + closes [#1310](https://github.com/krtirtho/spotube/issues/1310) + [#1311](https://github.com/krtirtho/spotube/issues/1311) ### Bug Fixes -- instance of Artist bug [#1362](https://github.com/krtirtho/spotube/issues/1362) ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) -- **playback:** sponsor block skips and stutters in same position ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) +- instance of Artist bug + [#1362](https://github.com/krtirtho/spotube/issues/1362) + ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) +- **playback:** sponsor block skips and stutters in same position + ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) ## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) ### Features -- add endless playback support [#285](https://github.com/krtirtho/spotube/issues/285) ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) -- add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) -- Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) -- add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) -- **playlist:** show confirmation before deleting user playlist [#1222](https://github.com/krtirtho/spotube/issues/1222) ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) -- Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) -- start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) -- **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) -- **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) -- **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) +- add endless playback support + [#285](https://github.com/krtirtho/spotube/issues/285) + ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) +- add getting started page + ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) +- Add iOS background play support + ([#1166](https://github.com/krtirtho/spotube/issues/1166)) + ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) +- add songlink based track matching for youtube and open song link button + ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) +- **playlist:** show confirmation before deleting user playlist + [#1222](https://github.com/krtirtho/spotube/issues/1222) + ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) +- Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) + ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) +- start radio support + ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) +- **translations:** add Korean translation + ([#1275](https://github.com/krtirtho/spotube/issues/1275)) + ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) +- **translations:** Added Vietnamese + ([#1135](https://github.com/krtirtho/spotube/issues/1135)) + ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) +- **windows:** Install Visual C++ 2015-2022 Redistributable if missing when + installing + ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) ### Bug Fixes -- album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) -- album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) -- **android:** audio issue when screen is off and broadcast audio session id ([#1221](https://github.com/krtirtho/spotube/issues/1221) & [#1247](https://github.com/krtirtho/spotube/issues/1247)) ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) -- **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) -- **android:** pressing back button in any other tab other than home exits the app ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) -- **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) -- cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) -- friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) -- no draggable scrollbar in playlist/album page [#1158](https://github.com/krtirtho/spotube/issues/1158) ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) -- non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) -- track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) -- **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) +- album images are small in certain places + ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) +- album, artist page not loading + [#1282](https://github.com/krtirtho/spotube/issues/1282) + ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) +- **android:** audio issue when screen is off and broadcast audio session id + ([#1221](https://github.com/krtirtho/spotube/issues/1221) & + [#1247](https://github.com/krtirtho/spotube/issues/1247)) + ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), + closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) +- **android:** only ask battery optimization once + [#1252](https://github.com/krtirtho/spotube/issues/1252) + ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) +- **android:** pressing back button in any other tab other than home exits the + app + ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) +- **android:** system back button in player page exits the app + ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) +- cleanTitle removing feat and ft from words instead of whole words + ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) +- friends list not scrollable with mouse drag + ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) +- no draggable scrollbar in playlist/album page + [#1158](https://github.com/krtirtho/spotube/issues/1158) + ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) +- non-banger songs breaking the queue if sources not found + ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) +- track loading when not found in Youtube + ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) +- **translations:** Update app_nl.arb + ([#1168](https://github.com/krtirtho/spotube/issues/1168)) + ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) ## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) ### Features -- add create playlist button in add playlist dialog ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) -- add spotify friends activity ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) -- **deep-link:** add track opening page ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) -- haptic feedback on long press and reordering actions ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) -- improve youtube/piped matching by suffixing "- Topic" ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) -- **translations:** add Nepali (नेपाली) translations ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) +- add create playlist button in add playlist dialog + ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) +- add spotify friends activity + ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) + ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) +- **deep-link:** add track opening page + ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) +- haptic feedback on long press and reordering actions + ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) +- improve youtube/piped matching by suffixing "- Topic" + ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) +- **translations:** add Nepali (नेपाली) translations + ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) + ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), + closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) + [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) ### Bug Fixes -- alternative searched sources doesn't play [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) -- alternative source doesn't persist on next restart [#840](https://personal.github.com/krtirtho/spotube/issues/840) ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) -- **android:** download failing for permission issues [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) -- artist page error [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) -- audio resumes after a phone call even if it was paused before [#926](https://personal.github.com/krtirtho/spotube/issues/926) ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) -- better error message for failing to find lyrics [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) -- Black window flash when starting the app ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) -- **linux:** crash after login ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) -- **macos:** backbutton and window button overlap and unused empty space on home ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) -- **macos:** download folder unchangeable ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) -- **macos:** Respect Minimize to tray option ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) -- **macos:** system tray shows name and sidebar weird gap [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) -- releases section is empty when user doesn't follow any artists [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) -- search page vertical scrollbar moves on horizontal scroll [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) -- songs doesn't play when sources with preferred audio codec is empty ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) -- track index not showing after 200 ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) -- track pad horizontal scrolling not working ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) +- alternative searched sources doesn't play + [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) + ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) +- alternative source doesn't persist on next restart + [#840](https://personal.github.com/krtirtho/spotube/issues/840) + ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) +- **android:** download failing for permission issues + [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) + ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) +- artist page error + [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) + ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) +- audio resumes after a phone call even if it was paused before + [#926](https://personal.github.com/krtirtho/spotube/issues/926) + ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) +- better error message for failing to find lyrics + [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) + ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) +- Black window flash when starting the app + ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) + ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) +- **linux:** crash after login + ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) +- **macos:** backbutton and window button overlap and unused empty space on home + ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) +- **macos:** download folder unchangeable + ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) +- **macos:** Respect Minimize to tray option + ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) + ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) +- **macos:** system tray shows name and sidebar weird gap + [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) + ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) +- releases section is empty when user doesn't follow any artists + [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) + ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) +- search page vertical scrollbar moves on horizontal scroll + [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) + ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) +- songs doesn't play when sources with preferred audio codec is empty + ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) + ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) +- track index not showing after 200 + ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) +- track pad horizontal scrolling not working + ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) ## [3.4.0](https://github.com/KRTirtho/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) ### Features -- Add Go to Album option in track option [#917](https://github.com/KRTirtho/spotube/issues/917) ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) -- **translations:** add Italian language translations ([#818](https://github.com/KRTirtho/spotube/issues/818)) ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), closes [#676](https://github.com/KRTirtho/spotube/issues/676) [#676](https://github.com/KRTirtho/spotube/issues/676) -- compact genre view in home page ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) -- Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) -- improve loading animations ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) -- toggle for discord rpc ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) -- **translations:** add Dutch Language ([#969](https://github.com/KRTirtho/spotube/issues/969)) ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) +- Add Go to Album option in track option + [#917](https://github.com/KRTirtho/spotube/issues/917) + ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) +- **translations:** add Italian language translations + ([#818](https://github.com/KRTirtho/spotube/issues/818)) + ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), + closes [#676](https://github.com/KRTirtho/spotube/issues/676) + [#676](https://github.com/KRTirtho/spotube/issues/676) +- compact genre view in home page + ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) +- Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) + ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) +- improve loading animations + ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) +- toggle for discord rpc + ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) +- **translations:** add Dutch Language + ([#969](https://github.com/KRTirtho/spotube/issues/969)) + ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) ### Bug Fixes -- add safe area in home ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) -- amoled mode and color scheme can't be changed ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) -- doesn't minimize to tray when system title bar close button is used [#866](https://github.com/KRTirtho/spotube/issues/866) ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) -- genre border issues ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) -- Incorrect "Artist" label/heading on Search Results Page [#920](https://github.com/KRTirtho/spotube/issues/920) ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) -- metadata not getting added for YouTube tracks [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) -- Playlist refresh not working [#915](https://github.com/KRTirtho/spotube/issues/915) ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) -- track view header title overflow and player view drag glitch ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) -- wrong artist name sent while scrobbling [#958](https://github.com/KRTirtho/spotube/issues/958) ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) +- add safe area in home + ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) +- amoled mode and color scheme can't be changed + ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) +- doesn't minimize to tray when system title bar close button is used + [#866](https://github.com/KRTirtho/spotube/issues/866) + ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) +- genre border issues + ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) +- Incorrect "Artist" label/heading on Search Results Page + [#920](https://github.com/KRTirtho/spotube/issues/920) + ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) +- metadata not getting added for YouTube tracks + [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of + downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) + ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) +- Playlist refresh not working + [#915](https://github.com/KRTirtho/spotube/issues/915) + ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) +- track view header title overflow and player view drag glitch + ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) +- wrong artist name sent while scrobbling + [#958](https://github.com/KRTirtho/spotube/issues/958) + ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) ## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) ### Features -- Add JioSaavn as audio source ([#881](https://github.com/KRTirtho/spotube/issues/881)) ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) -- **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) -- **artist:** modularize page and add wikipedia section ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) -- discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) -- **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) -- paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) -- **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) +- Add JioSaavn as audio source + ([#881](https://github.com/KRTirtho/spotube/issues/881)) + ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) +- **android:** better quick scroll/drag to scroll implementation + ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) +- **artist:** modularize page and add wikipedia section + ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) +- discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) + ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) +- **mini_player:** show/hide lyrics + [#851](https://github.com/KRTirtho/spotube/issues/851) + ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) +- paginated playlist and album page + ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) +- **translations:** add Turkish translations + ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) ### Bug Fixes -- "Add () to Playlist" option not showing in favorited playlists [#904](https://github.com/KRTirtho/spotube/issues/904) ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) -- 0:00 media duration in queue after application restart [#782](https://github.com/KRTirtho/spotube/issues/782) ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) -- Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) -- **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) -- alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) -- android invalid download location Download not starting or not explaining error [#720](https://github.com/KRTirtho/spotube/issues/720) ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) -- changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) -- check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) -- **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) -- infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) -- last track of queue keeps repeating [#718](https://github.com/KRTirtho/spotube/issues/718) ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) -- Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) -- new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) -- **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) -- scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) -- settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) -- shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) -- spotube doesn't exit properly, hangs in infinite loop [#768](https://github.com/KRTirtho/spotube/issues/768) ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) -- trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) -- use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) -- user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) -- **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) +- "Add () to Playlist" option not showing in favorited playlists + [#904](https://github.com/KRTirtho/spotube/issues/904) + ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) +- 0:00 media duration in queue after application restart + [#782](https://github.com/KRTirtho/spotube/issues/782) + ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) +- Add to Playlist Dialog memory leak + [#817](https://github.com/KRTirtho/spotube/issues/817) + ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) +- **album_card:** show loading state during adding track to queue/play + ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) +- alternative track source safearea overflow + [#876](https://github.com/KRTirtho/spotube/issues/876) + ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) +- android invalid download location Download not starting or not explaining + error [#720](https://github.com/KRTirtho/spotube/issues/720) + ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) +- changed settings are not persisting after force stop + [#821](https://github.com/KRTirtho/spotube/issues/821) + ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) +- check for unsynced lyrics and error handling for timed lyrics query + ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) +- **genres:** lag while scrolling + ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) +- infinite list disappearing for a moment everytime new page is fetched + ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) +- last track of queue keeps repeating + [#718](https://github.com/KRTirtho/spotube/issues/718) + ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) +- Navigating to settings, redirects to home page + [#812](https://github.com/KRTirtho/spotube/issues/812) + ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) +- new releases section flickering on scroll glitch + ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) +- **playbutton_card:** annoying animation + ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) +- scrobbling not working for first track or single track + ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) +- settings page scrollbar position + ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) +- shuffle doesn't move active track to top + ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) +- spotube doesn't exit properly, hangs in infinite loop + [#768](https://github.com/KRTirtho/spotube/issues/768) + ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) +- trim login field padding + ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) +- use CustomScrollView for personalized page + ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) +- user_playlists layout, track tile index, + ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) +- **windows:** media control not working + [#641](https://github.com/KRTirtho/spotube/issues/641) + ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) ## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) ### Features -- ability to select/copy lyrics [#802](https://github.com/KRTirtho/spotube/issues/802) ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) -- add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) -- add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) -- add restore default settings button ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) -- configurable audio normalization switch ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) -- customizable stream/download file formats ([#757](https://github.com/KRTirtho/spotube/issues/757)) ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) -- improve and unify the logging framework ([#738](https://github.com/KRTirtho/spotube/issues/738)) ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) -- LastFM scrobbling support ([#761](https://github.com/KRTirtho/spotube/issues/761)) ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) -- loading indicator for genre and personalized pages ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) -- manual offline detection ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) -- show error dialog on failed to login ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) -- sliding up player support ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) -- swipe to open player view ([#765](https://github.com/KRTirtho/spotube/issues/765)) ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) -- thicken the scrollbars & make 'em interactive for mobile ([#764](https://github.com/KRTirtho/spotube/issues/764)) ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) -- **translations:** add Arabic Translations ([#740](https://github.com/KRTirtho/spotube/issues/740)) ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) -- **translations:** add Farsi Translations ([#760](https://github.com/KRTirtho/spotube/issues/760)) ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) +- ability to select/copy lyrics + [#802](https://github.com/KRTirtho/spotube/issues/802) + ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) +- add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) + ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) +- add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) + ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) +- add restore default settings button + ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) +- configurable audio normalization switch + ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) +- customizable stream/download file formats + ([#757](https://github.com/KRTirtho/spotube/issues/757)) + ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) +- improve and unify the logging framework + ([#738](https://github.com/KRTirtho/spotube/issues/738)) + ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) +- LastFM scrobbling support + ([#761](https://github.com/KRTirtho/spotube/issues/761)) + ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) +- loading indicator for genre and personalized pages + ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) +- manual offline detection + ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) +- show error dialog on failed to login + ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) +- sliding up player support + ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) +- swipe to open player view + ([#765](https://github.com/KRTirtho/spotube/issues/765)) + ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) +- thicken the scrollbars & make 'em interactive for mobile + ([#764](https://github.com/KRTirtho/spotube/issues/764)) + ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) +- **translations:** add Arabic Translations + ([#740](https://github.com/KRTirtho/spotube/issues/740)) + ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) +- **translations:** add Farsi Translations + ([#760](https://github.com/KRTirtho/spotube/issues/760)) + ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) ### Bug Fixes -- add libmpv1 for ubuntu-based systems ([#739](https://github.com/KRTirtho/spotube/issues/739)) ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) -- add xdg-user-dirs as deps ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) -- **android :** file_selector getDirectoryPath returns unusable content urls [#720](https://github.com/KRTirtho/spotube/issues/720) ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) -- **android:** audio doesn't resume on interruption end ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) -- **android:** system navigator back doesn't close player ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) -- get rid of overflow errors & status bar dark color ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) -- keyboard shortcuts changing route but not update sidebar ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) -- last track repeats ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) -- minor glitches ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) -- not fetching all followed artists ([#759](https://github.com/KRTirtho/spotube/issues/759)) ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) -- use audio_service_mpris plugin ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) -- valid non-ASCII characters get removed from downloaded file name [#745](https://github.com/KRTirtho/spotube/issues/745) ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) +- add libmpv1 for ubuntu-based systems + ([#739](https://github.com/KRTirtho/spotube/issues/739)) + ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) +- add xdg-user-dirs as deps + ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) +- **android :** file_selector getDirectoryPath returns unusable content urls + [#720](https://github.com/KRTirtho/spotube/issues/720) + ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) +- **android:** audio doesn't resume on interruption end + ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) +- **android:** system navigator back doesn't close player + ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) +- get rid of overflow errors & status bar dark color + ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) +- keyboard shortcuts changing route but not update sidebar + ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) +- last track repeats + ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) +- minor glitches + ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) +- not fetching all followed artists + ([#759](https://github.com/KRTirtho/spotube/issues/759)) + ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) +- use audio_service_mpris plugin + ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) +- valid non-ASCII characters get removed from downloaded file name + [#745](https://github.com/KRTirtho/spotube/issues/745) + ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) ## [3.1.2](https://github.com/KRTirtho/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) ### Features -- **player_queue:** filtering track support ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) -- right click to open track option ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) -- search loading animation ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) -- show loading indicator on play track ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) +- **player_queue:** filtering track support + ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) +- right click to open track option + ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) +- search loading animation + ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) +- show loading indicator on play track + ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) ### Bug Fixes -- add missing dependency in debian package ([#704](https://github.com/KRTirtho/spotube/issues/704)) ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) -- hour not showing for tracks longer than 60 minutes ([#648](https://github.com/KRTirtho/spotube/issues/648)) ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) -- liked tracks card play not working ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) -- limit cover image upload to allowed 256kb size ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) -- playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) -- rewind breaks track progress bar ([#695](https://github.com/KRTirtho/spotube/issues/695)) ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) -- Windows memory leak due refetchOnStale user-liked-tracks ([#705](https://github.com/KRTirtho/spotube/issues/705)) ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) +- add missing dependency in debian package + ([#704](https://github.com/KRTirtho/spotube/issues/704)) + ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) +- hour not showing for tracks longer than 60 minutes + ([#648](https://github.com/KRTirtho/spotube/issues/648)) + ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) +- liked tracks card play not working + ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) +- limit cover image upload to allowed 256kb size + ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) +- playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) + ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) +- rewind breaks track progress bar + ([#695](https://github.com/KRTirtho/spotube/issues/695)) + ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) +- Windows memory leak due refetchOnStale user-liked-tracks + ([#705](https://github.com/KRTirtho/spotube/issues/705)) + ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) ## [3.1.1](https://github.com/KRTirtho/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) ### Features -- ability to toggle system title bar & custom title bar ([#185](https://github.com/KRTirtho/spotube/issues/185)) ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) -- jump to specific time on lyric click ([#590](https://github.com/KRTirtho/spotube/issues/590)) ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) -- paginated user albums ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) -- **translations:** add Russian translation ([#661](https://github.com/KRTirtho/spotube/issues/661)) ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), closes [#625](https://github.com/KRTirtho/spotube/issues/625) -- **translations:** added Portuguese (Brazil) translation ([#634](https://github.com/KRTirtho/spotube/issues/634)) ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) +- ability to toggle system title bar & custom title bar + ([#185](https://github.com/KRTirtho/spotube/issues/185)) + ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) +- jump to specific time on lyric click + ([#590](https://github.com/KRTirtho/spotube/issues/590)) + ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) +- paginated user albums + ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) +- **translations:** add Russian translation + ([#661](https://github.com/KRTirtho/spotube/issues/661)) + ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), + closes [#625](https://github.com/KRTirtho/spotube/issues/625) +- **translations:** added Portuguese (Brazil) translation + ([#634](https://github.com/KRTirtho/spotube/issues/634)) + ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) ### Bug Fixes -- always fetching SponsorBlock if no segments found & download failing ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) -- debian bookworm invalid dependencies ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) -- disable android auto for playstore version :"( ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) -- infinite route push glitch ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) -- jump to track going to wrong track ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) -- last track of queue never plays & repeat playlist never works ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) -- lyrics page text contrast ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) -- replace connectivity_plus with internet_connection_checker ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) -- sanitize song title for file name ([#644](https://github.com/KRTirtho/spotube/issues/644)) ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) -- sorting by date crashes app ([#551](https://github.com/KRTirtho/spotube/issues/551)) ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) -- window size remains same after exiting mini player ([#618](https://github.com/KRTirtho/spotube/issues/618)) ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) +- always fetching SponsorBlock if no segments found & download failing + ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) +- debian bookworm invalid dependencies + ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) +- disable android auto for playstore version :"( + ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) +- infinite route push glitch + ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) +- jump to track going to wrong track + ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) +- last track of queue never plays & repeat playlist never works + ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) +- lyrics page text contrast + ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) +- replace connectivity_plus with internet_connection_checker + ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) +- sanitize song title for file name + ([#644](https://github.com/KRTirtho/spotube/issues/644)) + ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) +- sorting by date crashes app + ([#551](https://github.com/KRTirtho/spotube/issues/551)) + ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) +- window size remains same after exiting mini player + ([#618](https://github.com/KRTirtho/spotube/issues/618)) + ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) ## [3.1.0](https://github.com/KRTirtho/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) ### Features -- add android auto media session control support ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) -- better track matching on youtube API ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) -- blazingly™ fast download manager ([#619](https://github.com/KRTirtho/spotube/issues/619)) ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) -- paginated user playlists ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) -- show error dialog on piped API 500 error ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) -- **translation:** add catalan translations ([#621](https://github.com/KRTirtho/spotube/issues/621)) ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) -- **translations:** add polish translation ([#631](https://github.com/KRTirtho/spotube/issues/631)) ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) -- web compatibility ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) +- add android auto media session control support + ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) +- better track matching on youtube API + ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) +- blazingly™ fast download manager + ([#619](https://github.com/KRTirtho/spotube/issues/619)) + ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) +- paginated user playlists + ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) +- show error dialog on piped API 500 error + ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) +- **translation:** add catalan translations + ([#621](https://github.com/KRTirtho/spotube/issues/621)) + ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) +- **translations:** add polish translation + ([#631](https://github.com/KRTirtho/spotube/issues/631)) + ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) +- web compatibility + ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) ### Bug Fixes -- always showing play in playlist/album views ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) -- **android:** android 13 local tracks not showing up ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) -- default to youtube API by default ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) -- generate playlist page max width ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) -- tracks doesn't change when ended ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) -- windows media controls not working ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) +- always showing play in playlist/album views + ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) +- **android:** android 13 local tracks not showing up + ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) +- default to youtube API by default + ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) +- generate playlist page max width + ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) +- tracks doesn't change when ended + ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) +- windows media controls not working + ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) ## [3.0.1](https://github.com/KRTirtho/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) ### Features -- Force High Refresh Rate on some Android devices ([#607](https://github.com/KRTirtho/spotube/issues/607)) ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) -- **translations:** add spanish translations ([#585](https://github.com/KRTirtho/spotube/issues/585)) ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) -- **translations:** add Simplified Chinese translation. ([#556](https://github.com/KRTirtho/spotube/issues/556)) ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) +- Force High Refresh Rate on some Android devices + ([#607](https://github.com/KRTirtho/spotube/issues/607)) + ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) +- **translations:** add spanish translations + ([#585](https://github.com/KRTirtho/spotube/issues/585)) + ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) +- **translations:** add Simplified Chinese translation. + ([#556](https://github.com/KRTirtho/spotube/issues/556)) + ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) ### Bug Fixes -- alternative track source textfield safe area ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) -- avoid sponsor block for first few seconds to not break the stream ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) -- cache segments casting error ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) -- duration is always zero in PlayerView ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) -- flags not showing up and html in descriptions ([5a563ef](https://github.com/KRTirtho/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) -- **linux:** crash when no secret service provider found ([#608](https://github.com/KRTirtho/spotube/issues/608)) ([888a4b1](https://github.com/KRTirtho/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) -- login dialog stays after login, mention sp_gaid in tutorial ([b492840](https://github.com/KRTirtho/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) -- **album_sync**: negative index exception in update palette ([#561](https://github.com/KRTirtho/spotube/issues/561)) ([0089d47](https://github.com/KRTirtho/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) -- remove adaptive widgets ([#520](https://github.com/KRTirtho/spotube/issues/520)) ([e4cbdd3](https://github.com/KRTirtho/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) -- shuffle not working ([#562](https://github.com/KRTirtho/spotube/issues/562)) ([dc76634](https://github.com/KRTirtho/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) -- track not skipping to next even when source is available ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) +- alternative track source textfield safe area + ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) +- avoid sponsor block for first few seconds to not break the stream + ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) +- cache segments casting error + ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) +- duration is always zero in PlayerView + ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) +- flags not showing up and html in descriptions + ([5a563ef](https://github.com/KRTirtho/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) +- **linux:** crash when no secret service provider found + ([#608](https://github.com/KRTirtho/spotube/issues/608)) + ([888a4b1](https://github.com/KRTirtho/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) +- login dialog stays after login, mention sp_gaid in tutorial + ([b492840](https://github.com/KRTirtho/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) +- **album_sync**: negative index exception in update palette + ([#561](https://github.com/KRTirtho/spotube/issues/561)) + ([0089d47](https://github.com/KRTirtho/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) +- remove adaptive widgets + ([#520](https://github.com/KRTirtho/spotube/issues/520)) + ([e4cbdd3](https://github.com/KRTirtho/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) +- shuffle not working ([#562](https://github.com/KRTirtho/spotube/issues/562)) + ([dc76634](https://github.com/KRTirtho/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) +- track not skipping to next even when source is available + ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) ## [3.0.0](https://github.com/KRTirtho/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) ### Features -- adaptive controllers ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) -- adaptive popup and bottom sheet list widget ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) -- add generated to playlist(s) ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) -- add german locale ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) -- add piped search mode ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) -- add sleep timer support ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) -- adjust lyric page blurriness and player playbutton ([54d5907](https://github.com/KRTirtho/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) -- album art dominant color as accent color ([#447](https://github.com/KRTirtho/spotube/issues/447)) ([31b9249](https://github.com/KRTirtho/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) -- **android:** add splash screen ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) -- **android:** disable battery optimization for better playback ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) -- artist card redesign ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) -- Better download manager with download progress ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) -- better language picker, adaptive select tile and settings section contrast ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) -- cache encryption for sensitive data ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) -- color scheme picker dialog vertical list view instead of wrap ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) -- compact and adaptive playbutton card design ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) -- compact button tabbar ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) -- create a basic installer script ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) -- curved navigation bar ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) -- custom error toast ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) -- custom playlist generator ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) -- desktop mini player support ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) -- **desktop:** close button for minimize notification ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) -- **desktop:** show minimized to system tray notification ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) -- disable/enable smtc on demand ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) -- download button on each track ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) -- enable caching of queue ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) -- heart button animation ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) -- improved track item API and UI ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) -- **installer:** get latest version from Github API ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) -- **local_tracks:** delete local track ([#484](https://github.com/KRTirtho/spotube/issues/484)) ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) -- locale category/genre title ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) -- **locale:** add bengali translations for search page ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) -- **locale:** localize search, library, lyrics, artist with both Bengali and English ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) -- **locale:** player, playlist view, track tile bengali and english translations ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) -- localize settings, about, login, player queue with Bengali and English translations ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) -- logs page in settings ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) -- macos title bar spacing and lyrics page margin separate ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) -- make snackbar floating ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) -- merge floating player with nav bar and nav bar translucent bg ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) -- **mini_player:** remove window shadow ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) -- **mini_player:** show/hide UI on hover toggle ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) -- new sidebar widget and translucent bottom player ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) -- newly released albums of user followed artist ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) -- optimize image load + genre page and reduce page size of loaded categories ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) -- persistent volume percentage ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) -- personal playlist recommendations ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) -- piped instance picker on settings ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) -- platform specific title bar buttons ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) -- **playback:** integrate android, ios, macos with JustAudio ([d487fe5](https://github.com/KRTirtho/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) -- **playback:** use assets_audio_player to fix macos double duration problems and android high loading latency ([1fff0f1](https://github.com/KRTirtho/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) -- player details dialog and separate location of lyrics button in player page ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) -- **player:** add playlist related methods to audio player ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) -- **player:** animated gradient background ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) -- **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) -- **player:** proper coloring of elements ([b2c4ea1](https://github.com/KRTirtho/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) -- **player:** replace bg blur with gradient, proper fg color and align title and artist name ([159f03e](https://github.com/KRTirtho/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) -- playlist create support for generated playlist ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) -- playlist generation all parameters support ([9877d5f](https://github.com/KRTirtho/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) -- **playlist,album page:** play and shuffle take full width on smaller screens, add new xs breakpoint ([dce1b88](https://github.com/KRTirtho/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) -- **queue:** add track(s) for playing next ([#460](https://github.com/KRTirtho/spotube/issues/460)) ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) -- **queue:** reorder tracks support ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) -- re-designed playlist/album page ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) -- re-introduce youtube API along with piped ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) -- reactive volume slider and slicker bottom bar with lowered height ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) -- remove SponsorBlock in favor of YT Music and remove pocketbase backend track support ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) -- repeat button all 3 mode and disable player controls when track is fetching ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) -- replace YouTube API with piped API ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) -- responsive playlist generate page and scrollable multi autocomplete ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) -- search alternative track source ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) -- setup localization (l10n) and language switcher, add sidebar and navbar locale ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) -- show album release year ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of artist's album ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) -- show country code piped instance list ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) -- show loading when track metadata is being fetched, android, ios, macos enable shuffling ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) -- sort tracks by newest and oldest dates ([b4713e3](https://github.com/KRTirtho/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) -- supabase integration ([8bcce92](https://github.com/KRTirtho/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) -- system tray support ([#31](https://github.com/KRTirtho/spotube/issues/31)) ([06a0437](https://github.com/KRTirtho/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) -- track populate sibling support ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) -- **translation:** add hindi and french translations using ChatGPT ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) -- **translation:** add Japanase locale ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) -- use system color scheme ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) -- volume slider in player page ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) -- windows OS media control panel support ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) +- adaptive controllers + ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) +- adaptive popup and bottom sheet list widget + ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) +- add generated to playlist(s) + ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) +- add german locale + ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) +- add piped search mode + ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) +- add sleep timer support + ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) +- adjust lyric page blurriness and player playbutton + ([54d5907](https://github.com/KRTirtho/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) +- album art dominant color as accent color + ([#447](https://github.com/KRTirtho/spotube/issues/447)) + ([31b9249](https://github.com/KRTirtho/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) +- **android:** add splash screen + ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) +- **android:** disable battery optimization for better playback + ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) +- artist card redesign + ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) +- Better download manager with download progress + ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) +- better language picker, adaptive select tile and settings section contrast + ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) +- cache encryption for sensitive data + ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) +- color scheme picker dialog vertical list view instead of wrap + ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) +- compact and adaptive playbutton card design + ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) +- compact button tabbar + ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) +- create a basic installer script + ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) +- curved navigation bar + ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) +- custom error toast + ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) +- custom playlist generator + ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) +- desktop mini player support + ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) +- **desktop:** close button for minimize notification + ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) +- **desktop:** show minimized to system tray notification + ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) +- disable/enable smtc on demand + ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) +- download button on each track + ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) +- enable caching of queue + ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) +- heart button animation + ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) +- improved track item API and UI + ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) +- **installer:** get latest version from Github API + ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) +- **local_tracks:** delete local track + ([#484](https://github.com/KRTirtho/spotube/issues/484)) + ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) +- locale category/genre title + ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) +- **locale:** add bengali translations for search page + ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) +- **locale:** localize search, library, lyrics, artist with both Bengali and + English + ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) +- **locale:** player, playlist view, track tile bengali and english translations + ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) +- localize settings, about, login, player queue with Bengali and English + translations + ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) +- logs page in settings + ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) +- macos title bar spacing and lyrics page margin separate + ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) +- make snackbar floating + ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) +- merge floating player with nav bar and nav bar translucent bg + ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) +- **mini_player:** remove window shadow + ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) +- **mini_player:** show/hide UI on hover toggle + ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) +- new sidebar widget and translucent bottom player + ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) +- newly released albums of user followed artist + ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) +- optimize image load + genre page and reduce page size of loaded categories + ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) +- persistent volume percentage + ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) +- personal playlist recommendations + ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) +- piped instance picker on settings + ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) +- platform specific title bar buttons + ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) +- **playback:** integrate android, ios, macos with JustAudio + ([d487fe5](https://github.com/KRTirtho/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) +- **playback:** use assets_audio_player to fix macos double duration problems + and android high loading latency + ([1fff0f1](https://github.com/KRTirtho/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) +- player details dialog and separate location of lyrics button in player page + ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) +- **player:** add playlist related methods to audio player + ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) +- **player:** animated gradient background + ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) +- **player:** custom playlist implementation for media_kit to replace + unpredictable playlist of mpv + ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) +- **player:** proper coloring of elements + ([b2c4ea1](https://github.com/KRTirtho/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) +- **player:** replace bg blur with gradient, proper fg color and align title and + artist name + ([159f03e](https://github.com/KRTirtho/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) +- playlist create support for generated playlist + ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) +- playlist generation all parameters support + ([9877d5f](https://github.com/KRTirtho/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) +- **playlist,album page:** play and shuffle take full width on smaller screens, + add new xs breakpoint + ([dce1b88](https://github.com/KRTirtho/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) +- **queue:** add track(s) for playing next + ([#460](https://github.com/KRTirtho/spotube/issues/460)) + ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) +- **queue:** reorder tracks support + ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) +- re-designed playlist/album page + ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) +- re-introduce youtube API along with piped + ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) +- reactive volume slider and slicker bottom bar with lowered height + ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) +- remove SponsorBlock in favor of YT Music and remove pocketbase backend track + support + ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) +- repeat button all 3 mode and disable player controls when track is fetching + ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) +- replace YouTube API with piped API + ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) +- responsive playlist generate page and scrollable multi autocomplete + ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) +- search alternative track source + ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) +- setup localization (l10n) and language switcher, add sidebar and navbar locale + ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) +- show album release year + ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of + artist's album + ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) +- show country code piped instance list + ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) +- show loading when track metadata is being fetched, android, ios, macos enable + shuffling + ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) +- sort tracks by newest and oldest dates + ([b4713e3](https://github.com/KRTirtho/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) +- supabase integration + ([8bcce92](https://github.com/KRTirtho/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) +- system tray support ([#31](https://github.com/KRTirtho/spotube/issues/31)) + ([06a0437](https://github.com/KRTirtho/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) +- track populate sibling support + ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) +- **translation:** add hindi and french translations using ChatGPT + ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) +- **translation:** add Japanase locale + ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) +- use system color scheme + ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) +- volume slider in player page + ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) +- windows OS media control panel support + ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) ### Bug Fixes -- add to playlist dialog not showing playlist name ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) -- album sync not working ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) -- alternative track source not playing new source ([a9b5a71](https://github.com/KRTirtho/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) -- **android:** use multi assetAudioPlayer instance fix patch and disable Pre-download and play by default in Android too ([cdb3268](https://github.com/KRTirtho/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) -- **artist:** follower count shows as float when < 1000 ([#482](https://github.com/KRTirtho/spotube/issues/482)) ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) -- bottom navbar first item icon color not changing on primary color change ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) -- cached currently playing track infinite loading ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) -- cached queue tracks expired stream ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) -- collection currently playing state persist on restart ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) -- color not syncing and add new screenshot ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) -- content going below bottom player or nav bar ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) -- disable background_downloader due to android build failures ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) -- disable play when loading track and buffering event ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) -- error log ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) -- excessive repaints caused by Player progress bar ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) -- failed download no error icon ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) -- **home:** bottom player transparency ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) -- language changer not working ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) -- less frequent position updates ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) -- linux mpris not showing up and overall media notification service ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) -- local tracks getting fetched on first load ([73c012c](https://github.com/KRTirtho/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) -- local tracks not working when there's a invalid music file in the folder ([5855820](https://github.com/KRTirtho/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) -- lyrics page blur in player and cut off text when line too big ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) -- macos build by removing media_kit native event loop ([62fc773](https://github.com/KRTirtho/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) -- macos build error, mobile player duration and playing state and background disposal of player ([be91e33](https://github.com/KRTirtho/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) -- **macos,ios:** use regular shared prefs ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) -- memoize child of animated widget and make player bg animation faster ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) -- mini player not working in release mode ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) -- **mkPlayer:** remove method and wrong active index on modifying playlist ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) -- mobile audio notification not working ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) -- multiple instance of theme ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) -- navigation to settings not working ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) -- no progress update when track changed ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) -- null exception on proxy playlist and audio player ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) -- overflowing clickable artists links ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) -- personalized playlists not loading ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) -- playback not moving to next track after a track ends ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) -- **player:** gradient bg not taking full height ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) -- **player:** playback element placement ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) -- **player:** queue button not showing when not logged in ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) -- **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) -- playlist generate slider shape ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) -- pop sheet list not scrollable ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) -- re-enable add to queue and play next support, favorite button query exceptions ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) -- re-enable download manager ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) -- remove unnecessary broadcast stream conversions ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) -- remove useBreakpoints as it clogs up memory with unnecessary state updates ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) -- replace download multiple pops and add translations ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) -- screen breakpoints and persist lyrics delay across screens ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) -- sidebar task counter badge and bottom player play button progress color ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) -- status bar color of playlist/album page ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) -- system color scheme not persisting on restart when system color scheme changed ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) -- track collection view status bar not transparent ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) -- track doesn't play after change ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) -- track stops at last second ([f554f6d](https://github.com/KRTirtho/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) -- **track_collection_view:** keyboard focus on scroll and no space for search results in playlist/album ([7a8bd92](https://github.com/KRTirtho/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) -- track_table_view table headers ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) -- track_tile active and blacklist color, playbutton card action positioning ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) -- use id based source getters instead of index ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) +- add to playlist dialog not showing playlist name + ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) +- album sync not working + ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) +- alternative track source not playing new source + ([a9b5a71](https://github.com/KRTirtho/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) +- **android:** use multi assetAudioPlayer instance fix patch and disable + Pre-download and play by default in Android too + ([cdb3268](https://github.com/KRTirtho/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) +- **artist:** follower count shows as float when < 1000 + ([#482](https://github.com/KRTirtho/spotube/issues/482)) + ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) +- bottom navbar first item icon color not changing on primary color change + ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) +- cached currently playing track infinite loading + ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) +- cached queue tracks expired stream + ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) +- collection currently playing state persist on restart + ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) +- color not syncing and add new screenshot + ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) +- content going below bottom player or nav bar + ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) +- disable background_downloader due to android build failures + ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) +- disable play when loading track and buffering event + ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) +- error log + ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) +- excessive repaints caused by Player progress bar + ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) +- failed download no error icon + ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) +- **home:** bottom player transparency + ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) +- language changer not working + ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) +- less frequent position updates + ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) +- linux mpris not showing up and overall media notification service + ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) +- local tracks getting fetched on first load + ([73c012c](https://github.com/KRTirtho/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) +- local tracks not working when there's a invalid music file in the folder + ([5855820](https://github.com/KRTirtho/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) +- lyrics page blur in player and cut off text when line too big + ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) +- macos build by removing media_kit native event loop + ([62fc773](https://github.com/KRTirtho/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) +- macos build error, mobile player duration and playing state and background + disposal of player + ([be91e33](https://github.com/KRTirtho/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) +- **macos,ios:** use regular shared prefs + ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) +- memoize child of animated widget and make player bg animation faster + ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) +- mini player not working in release mode + ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) +- **mkPlayer:** remove method and wrong active index on modifying playlist + ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) +- mobile audio notification not working + ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) +- multiple instance of theme + ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) +- navigation to settings not working + ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) +- no progress update when track changed + ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) +- null exception on proxy playlist and audio player + ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) +- overflowing clickable artists links + ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) +- personalized playlists not loading + ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) +- playback not moving to next track after a track ends + ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) +- **player:** gradient bg not taking full height + ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) +- **player:** playback element placement + ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) +- **player:** queue button not showing when not logged in + ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) +- **player:** volume slider, prefetching of media_kit and stuttering on + sponsorblock skip + ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) +- playlist generate slider shape + ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) +- pop sheet list not scrollable + ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) +- re-enable add to queue and play next support, favorite button query exceptions + ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) +- re-enable download manager + ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) +- remove unnecessary broadcast stream conversions + ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) +- remove useBreakpoints as it clogs up memory with unnecessary state updates + ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) +- replace download multiple pops and add translations + ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) +- screen breakpoints and persist lyrics delay across screens + ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) +- sidebar task counter badge and bottom player play button progress color + ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) +- status bar color of playlist/album page + ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) +- system color scheme not persisting on restart when system color scheme changed + ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) +- track collection view status bar not transparent + ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) +- track doesn't play after change + ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) +- track stops at last second + ([f554f6d](https://github.com/KRTirtho/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) +- **track_collection_view:** keyboard focus on scroll and no space for search + results in playlist/album + ([7a8bd92](https://github.com/KRTirtho/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) +- track_table_view table headers + ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) +- track_tile active and blacklist color, playbutton card action positioning + ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) +- use id based source getters instead of index + ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) ### [2.7.1](https://github.com/KRTirtho/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) ### Bug Fixes -- fallback for lyrics when anonymous ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) -- **android:** audio notification stuck in play state ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) -- **macos:** crashing on startup ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) -- spotify query hooks overriding default query params ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) +- fallback for lyrics when anonymous + ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) +- **android:** audio notification stuck in play state + ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) +- **macos:** crashing on startup + ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) +- spotify query hooks overriding default query params + ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) ## [2.7.0](https://github.com/KRTirtho/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) ### Features -- add or remove track, playlist or album to queue support ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) -- basic command line argument support ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) -- black list artist or track ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) -- bring pre download on desktop, disable pre download for long videos ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) -- category/genre filter ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) -- centralized icon collection with new icon set and nav bar labels hidden ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) -- compact search bar for genres and user_local_tracks page ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) -- compatibility with fl-query nextPage method change ([7617439](https://github.com/KRTirtho/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) -- configure pocketbase, generate dart types, update playback to use server instead of hive cache ([ad90c11](https://github.com/KRTirtho/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) -- failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) -- **home:** personalized section ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) -- individual shuffle and repeat/loop button of player ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) -- **lyrics:** use official spotify API for fetching lyrics and add zoom controls ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) -- **mobile:** pull to refresh support in all refreshable list views ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) -- new logo and compact search in playlist/album in mobile ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) -- search/filter tracks inside playlist or album ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) -- show snackbar on adding playlist or tracks to queue ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) -- **theme:** use material3 monet for colors and remove background color preference ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) -- use catcher to handle exceptions ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) -- use typed assets instead of hard coded paths ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) -- user local tracks searchbar ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) -- **user-library:** filtering support for user albums and user artists ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) -- **user-library:** search for user playlists ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) +- add or remove track, playlist or album to queue support + ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) +- basic command line argument support + ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) +- black list artist or track + ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) +- bring pre download on desktop, disable pre download for long videos + ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) +- category/genre filter + ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) +- centralized icon collection with new icon set and nav bar labels hidden + ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) +- compact search bar for genres and user_local_tracks page + ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) +- compatibility with fl-query nextPage method change + ([7617439](https://github.com/KRTirtho/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) +- configure pocketbase, generate dart types, update playback to use server + instead of hive cache + ([ad90c11](https://github.com/KRTirtho/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) +- failsafe pocketbase requests, removal of unneeded preferences options & + vertical playbutton actions + ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) +- **home:** personalized section + ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) +- individual shuffle and repeat/loop button of player + ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) +- **lyrics:** use official spotify API for fetching lyrics and add zoom controls + ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) +- **mobile:** pull to refresh support in all refreshable list views + ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) +- new logo and compact search in playlist/album in mobile + ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) +- search/filter tracks inside playlist or album + ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) +- show snackbar on adding playlist or tracks to queue + ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) +- **theme:** use material3 monet for colors and remove background color + preference + ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) +- use catcher to handle exceptions + ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) +- use typed assets instead of hard coded paths + ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) +- user local tracks searchbar + ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) +- **user-library:** filtering support for user albums and user artists + ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) +- **user-library:** search for user playlists + ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) ### Bug Fixes -- **about:** license text hidden in the bottom of smaller screen devices ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) -- **about:** wrong link of License ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) -- genre and sidebar user logo not loading ([710f172](https://github.com/KRTirtho/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) -- lyrics modal sheet out of safe area so use 80% of screen height instead of full ([3db28f4](https://github.com/KRTirtho/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) -- lyrics not changing on track change ([c809d2d](https://github.com/KRTirtho/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) -- lyrics not refetching when tracked changed while being in another page and sidebar user avatar not showing on startup ([bd12675](https://github.com/KRTirtho/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) -- macOS logo placement ([c6a5d5f](https://github.com/KRTirtho/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) -- mobile track collection search bar position and page_window_title_bar exception on mobile platforms ([d0aaa97](https://github.com/KRTirtho/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) -- **play_overlay:** show progress indicator on song loading ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) -- **playback:** not skipping track's sponsorblock segments ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) -- playbutton card play state not changing ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) -- **playbutton_card:** play and add to queue needs 2 clicks work ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) -- **playbutton_card:** play and non play state correction ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) -- **playbutton_card:** title text overflow ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) -- **playbutton:** playing state is not updating when playlist is actually playing ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) -- **player_queue:** large clear button and macos exception ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) -- **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks ([42b3e11](https://github.com/KRTirtho/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) -- pre downloading not working properly, audio service circular deps and sibling not loading for backend track ([3ccb525](https://github.com/KRTirtho/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) -- search track play button isn't working ([0751f5e](https://github.com/KRTirtho/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) -- **search:** grey screen, only tracks update on new search string, playlists,albums,artists show up before hitting return/submit ([a774817](https://github.com/KRTirtho/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) -- **search:** has to submit twice for search results ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) -- titlebar maximize+restore button not working and less responsive title bar buttons ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) -- **track_collection_view:** hide search bar when sliver is collapsed ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) -- **track_tile:** cannot see track index above 99 ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) -- **track_tile:** track action popup not showing on narrow screens ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) -- **ui:** scaffold exception in fluent_ui ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) -- use chosen market for new release ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) +- **about:** license text hidden in the bottom of smaller screen devices + ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) +- **about:** wrong link of License + ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) +- genre and sidebar user logo not loading + ([710f172](https://github.com/KRTirtho/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) +- lyrics modal sheet out of safe area so use 80% of screen height instead of + full + ([3db28f4](https://github.com/KRTirtho/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) +- lyrics not changing on track change + ([c809d2d](https://github.com/KRTirtho/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) +- lyrics not refetching when tracked changed while being in another page and + sidebar user avatar not showing on startup + ([bd12675](https://github.com/KRTirtho/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) +- macOS logo placement + ([c6a5d5f](https://github.com/KRTirtho/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) +- mobile track collection search bar position and page_window_title_bar + exception on mobile platforms + ([d0aaa97](https://github.com/KRTirtho/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) +- **play_overlay:** show progress indicator on song loading + ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) +- **playback:** not skipping track's sponsorblock segments + ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) +- playbutton card play state not changing + ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) +- **playbutton_card:** play and add to queue needs 2 clicks work + ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) +- **playbutton_card:** play and non play state correction + ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) +- **playbutton_card:** title text overflow + ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) +- **playbutton:** playing state is not updating when playlist is actually + playing + ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) +- **player_queue:** large clear button and macos exception + ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) +- **playlist_queue:** load method not preserving the active track before + filtering blacklisted tracks + ([42b3e11](https://github.com/KRTirtho/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) +- pre downloading not working properly, audio service circular deps and sibling + not loading for backend track + ([3ccb525](https://github.com/KRTirtho/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) +- search track play button isn't working + ([0751f5e](https://github.com/KRTirtho/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) +- **search:** grey screen, only tracks update on new search string, + playlists,albums,artists show up before hitting return/submit + ([a774817](https://github.com/KRTirtho/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) +- **search:** has to submit twice for search results + ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) +- titlebar maximize+restore button not working and less responsive title bar + buttons + ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) +- **track_collection_view:** hide search bar when sliver is collapsed + ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) +- **track_tile:** cannot see track index above 99 + ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) +- **track_tile:** track action popup not showing on narrow screens + ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) +- **ui:** scaffold exception in fluent_ui + ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) +- use chosen market for new release + ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) ## [2.6.0](https://github.com/KRTirtho/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) ### Features -- add selected tracks to playlists, optimistic playlist remove track ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) -- added shuffle button in playlist and album section ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) -- **android-playback:** option to download track bytes and play instead of Streaming ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) -- change default platform option and platform specific back button ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) -- dialog logo for macos, settings more width for country picker ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) -- initial platform_ui integration ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) -- libadwaita theming, track tile and PlayButtonCard play button icon fix ([e795e23](https://github.com/KRTirtho/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) -- **lyrics:** tabs for both synced and static lyrics [#182](https://github.com/KRTirtho/spotube/issues/182) ([6b6907a](https://github.com/KRTirtho/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) -- new refined about page, update checker only check for same update channel ([4cadfa9](https://github.com/KRTirtho/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) -- pause track when seeking forward/back and keep audio session alive when paused/interrupted ([bc8a04e](https://github.com/KRTirtho/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) -- platform bottom navigation bar add ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) -- platform slider and progress indicator integration ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) -- platform title bar buttons add ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) -- **playback:** change current track youtube source panel and tooltips for player icon buttons ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) -- Player and Playbutton theme respect to platform ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) -- player queue and sibling tracks platform decoration ([39a7794](https://github.com/KRTirtho/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) -- **PlayerView:** shortcut button for opening lyrics [#273](https://github.com/KRTirtho/spotube/issues/273) ([1d4847a](https://github.com/KRTirtho/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) -- rename files to snake_case and reorganize folder structure ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) -- replace all types of buttons with platform buttons ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) -- rpm packaging support ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) -- **search:** infinite scroll for tracks, artists, playlists and albums ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) -- set platform to default platform on start up ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) -- shuffle keep playing track at top, linux title bar drag no working ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) -- sidebar download count and proper progress color in playbutton ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) -- static shimmer for track tile, playbutton card and track tile ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) -- tablet mode navigation bar & windows semi transparent bg, ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) -- **title_bar:** platform specific title bar ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) -- titlebar complete compatibility, platform specific login, library tabbar in titlebar ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) -- use platform checkbox ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) -- window blur effect add ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) +- add selected tracks to playlists, optimistic playlist remove track + ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) +- added shuffle button in playlist and album section + ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) +- **android-playback:** option to download track bytes and play instead of + Streaming + ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) +- change default platform option and platform specific back button + ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) +- dialog logo for macos, settings more width for country picker + ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) +- initial platform_ui integration + ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) +- libadwaita theming, track tile and PlayButtonCard play button icon fix + ([e795e23](https://github.com/KRTirtho/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) +- **lyrics:** tabs for both synced and static lyrics + [#182](https://github.com/KRTirtho/spotube/issues/182) + ([6b6907a](https://github.com/KRTirtho/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) +- new refined about page, update checker only check for same update channel + ([4cadfa9](https://github.com/KRTirtho/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) +- pause track when seeking forward/back and keep audio session alive when + paused/interrupted + ([bc8a04e](https://github.com/KRTirtho/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) +- platform bottom navigation bar add + ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) +- platform slider and progress indicator integration + ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) +- platform title bar buttons add + ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) +- **playback:** change current track youtube source panel and tooltips for + player icon buttons + ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) +- Player and Playbutton theme respect to platform + ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) +- player queue and sibling tracks platform decoration + ([39a7794](https://github.com/KRTirtho/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) +- **PlayerView:** shortcut button for opening lyrics + [#273](https://github.com/KRTirtho/spotube/issues/273) + ([1d4847a](https://github.com/KRTirtho/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) +- rename files to snake_case and reorganize folder structure + ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) +- replace all types of buttons with platform buttons + ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) +- rpm packaging support + ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) +- **search:** infinite scroll for tracks, artists, playlists and albums + ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) +- set platform to default platform on start up + ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) +- shuffle keep playing track at top, linux title bar drag no working + ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) +- sidebar download count and proper progress color in playbutton + ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) +- static shimmer for track tile, playbutton card and track tile + ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) +- tablet mode navigation bar & windows semi transparent bg, + ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) +- **title_bar:** platform specific title bar + ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) +- titlebar complete compatibility, platform specific login, library tabbar in + titlebar + ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) +- use platform checkbox + ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) +- window blur effect add + ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) ### Bug Fixes -- **ArtistCard:** linux shadow ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) -- **auth:** refresh access token timer not working ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) -- bottom navigation bar settings tile not active when selected ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) -- dialog logo in android, lyrics visible timer adjust button ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) -- heart button showing when not logged in, wrong login redirect ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) -- horizontal infinite lists doesn't fill the screen ([69995be](https://github.com/KRTirtho/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) -- ios dialog action buttons, local tracks crashing app, shimmer color and android wrong status bar color ([90c1200](https://github.com/KRTirtho/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) -- **login:** not working in android in Brazil or Ukraine regions ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) -- **macos:** black text in dark mode ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) -- **macos:** white text color in dark mode, text field white background ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) -- **mobile:** SafeArea bugs and back button color ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) -- null exception in themes ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) -- platform_ui local path ([00d0d38](https://github.com/KRTirtho/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) -- player view artist link when local playlist is playing, lyric delay adjust button alignment ([ee5c417](https://github.com/KRTirtho/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) -- remove windows background ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) -- search field ios dark icon , lyrics tabbar ios background color ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) -- settings Title alignment and play button card ripple effect in other platforms ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) -- shuffle play logic ([65cad07](https://github.com/KRTirtho/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) -- small minwidth of window in desktop, linux wrong light theme accent color, search field transparent background ([5b0e22c](https://github.com/KRTirtho/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) -- tooltips of menu and adaptive pop up menu ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) -- update download dialog blocking the UI ([3925f74](https://github.com/KRTirtho/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) -- user playlists not updating after creating/deleting, artist follow not updating after follow/unfollow ([6cc2a18](https://github.com/KRTirtho/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) -- **windows:** windows global title bar ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) +- **ArtistCard:** linux shadow + ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) +- **auth:** refresh access token timer not working + ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) +- bottom navigation bar settings tile not active when selected + ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) +- dialog logo in android, lyrics visible timer adjust button + ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) +- heart button showing when not logged in, wrong login redirect + ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) +- horizontal infinite lists doesn't fill the screen + ([69995be](https://github.com/KRTirtho/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) +- ios dialog action buttons, local tracks crashing app, shimmer color and + android wrong status bar color + ([90c1200](https://github.com/KRTirtho/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) +- **login:** not working in android in Brazil or Ukraine regions + ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) +- **macos:** black text in dark mode + ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) +- **macos:** white text color in dark mode, text field white background + ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) +- **mobile:** SafeArea bugs and back button color + ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) +- null exception in themes + ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) +- platform_ui local path + ([00d0d38](https://github.com/KRTirtho/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) +- player view artist link when local playlist is playing, lyric delay adjust + button alignment + ([ee5c417](https://github.com/KRTirtho/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) +- remove windows background + ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) +- search field ios dark icon , lyrics tabbar ios background color + ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) +- settings Title alignment and play button card ripple effect in other platforms + ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) +- shuffle play logic + ([65cad07](https://github.com/KRTirtho/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) +- small minwidth of window in desktop, linux wrong light theme accent color, + search field transparent background + ([5b0e22c](https://github.com/KRTirtho/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) +- tooltips of menu and adaptive pop up menu + ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) +- update download dialog blocking the UI + ([3925f74](https://github.com/KRTirtho/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) +- user playlists not updating after creating/deleting, artist follow not + updating after follow/unfollow + ([6cc2a18](https://github.com/KRTirtho/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) +- **windows:** windows global title bar + ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) ## [2.5.0](https://github.com/KRTirtho/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) ### Features -- animated transition of root PageWindowTitleBar ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) -- **auth:** new authentication flow using cookies and webview in android ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) -- **downloader:** replace /skip all choice for downloaded tracks ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) -- implemented go_route shell/nested route ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) -- **keyboard shortcuts:** play/pause on space, seek position on left/right ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) -- **keyboard-shortcuts:** home sidebar tab navigation and close app ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) -- smoother list using fl_query and waypoint ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) -- sort tracks in playlist, album and local tracks ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) -- use of smaller sized images in `TrackTile` ([0ca97b4](https://github.com/KRTirtho/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) -- volume slider mouse scroll and preference for Rotating Album Art [#255](https://github.com/KRTirtho/spotube/issues/255) ([edb6f3c](https://github.com/KRTirtho/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) +- animated transition of root PageWindowTitleBar + ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) +- **auth:** new authentication flow using cookies and webview in android + ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) +- **downloader:** replace /skip all choice for downloaded tracks + ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) +- implemented go_route shell/nested route + ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) +- **keyboard shortcuts:** play/pause on space, seek position on left/right + ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) +- **keyboard-shortcuts:** home sidebar tab navigation and close app + ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) +- smoother list using fl_query and waypoint + ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) +- sort tracks in playlist, album and local tracks + ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) +- use of smaller sized images in `TrackTile` + ([0ca97b4](https://github.com/KRTirtho/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) +- volume slider mouse scroll and preference for Rotating Album Art + [#255](https://github.com/KRTirtho/spotube/issues/255) + ([edb6f3c](https://github.com/KRTirtho/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) ### Bug Fixes -- **android:** file_picker and permission_handler failure for sdk < 33 ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) -- cached local track is fetched from network ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) -- categories not showing for oauth exception ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) -- **desktop:** maximized window size is stored and window maximized state doesn't persist ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) -- local audio doesn't get refreshed after getting permission ([618c6da](https://github.com/KRTirtho/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) -- no appropriate output when playlist is empty [#201](https://github.com/KRTirtho/spotube/issues/201) ([dbb81de](https://github.com/KRTirtho/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) -- PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) -- **web:** not building due to metadata_god ffi ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) +- **android:** file_picker and permission_handler failure for sdk < 33 + ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) +- cached local track is fetched from network + ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) +- categories not showing for oauth exception + ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) +- **desktop:** maximized window size is stored and window maximized state + doesn't persist + ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) +- local audio doesn't get refreshed after getting permission + ([618c6da](https://github.com/KRTirtho/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) +- no appropriate output when playlist is empty + [#201](https://github.com/KRTirtho/spotube/issues/201) + ([dbb81de](https://github.com/KRTirtho/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) +- PlayerOverlay not hiding when not playing and unneeded bottom space in + TrackTableView + ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) +- **web:** not building due to metadata_god ffi + ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) ## [2.4.1](https://github.com/KRTirtho/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) ### Features -- add macos audio metadata tags support ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) -- remove macos bounds for reading and writing audio metadata ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) -- **search:** horizontal swipe scroll support for Desktop platform ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) +- add macos audio metadata tags support + ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) +- remove macos bounds for reading and writing audio metadata + ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) +- **search:** horizontal swipe scroll support for Desktop platform + ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) ### Bug Fixes -- **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) -- **layout:** Fix adaptive UI not working correctly by providing a overriding option ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) -- **local-track:** throwing exception when downloadLocation is empty ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) +- **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app + ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) +- **layout:** Fix adaptive UI not working correctly by providing a overriding + option + ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) +- **local-track:** throwing exception when downloadLocation is empty + ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) ## [2.4.0](https://github.com/KRTirtho/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) ### Features -- Ability to change download location added ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) -- add download multi tracks support for mobile platform ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) -- add download queue for desktop & initial playlist download support ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) -- add download tab on library ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) -- add web support although nothing works just as expected ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) -- **broken:** Broken Warning! Initial Local Audio Player ([c3bf511](https://github.com/KRTirtho/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) -- **download:** track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog ([e217553](https://github.com/KRTirtho/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) -- **local-tracks:** complete support for local tracks ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) -- **mpris:** MPRIS metadata are now updated in realtime ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) -- **playback:** add repeat track support [#166](https://github.com/KRTirtho/spotube/issues/166) ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) -- **synced-lyrics:** animated active text size ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) -- **ui:** adaptive TrackTile actions & Setting ListTile ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) +- Ability to change download location added + ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) +- add download multi tracks support for mobile platform + ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) +- add download queue for desktop & initial playlist download support + ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) +- add download tab on library + ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) +- add web support although nothing works just as expected + ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) +- **broken:** Broken Warning! Initial Local Audio Player + ([c3bf511](https://github.com/KRTirtho/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) +- **download:** track table view multi select improvement, tap to play track + support, existing track replace confirmation dialog and bulk download + confirmation dialog + ([e217553](https://github.com/KRTirtho/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) +- **local-tracks:** complete support for local tracks + ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) +- **mpris:** MPRIS metadata are now updated in realtime + ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) +- **playback:** add repeat track support + [#166](https://github.com/KRTirtho/spotube/issues/166) + ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) +- **synced-lyrics:** animated active text size + ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) +- **ui:** adaptive TrackTile actions & Setting ListTile + ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) ### Bug Fixes -- **adaptive-list-tile:** dialog content not updating when content has changed ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) -- album & playlist card, player view and album view play button logic ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) -- **docs:** indentions ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) -- **downloader:** downloaded track is corrupted for tagging ([2ab1fba](https://github.com/KRTirtho/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) -- **downloader:** flutter downloader exception on desktop platform and too much width of TrackTile index no. ([d668760](https://github.com/KRTirtho/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) -- dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) -- flutter_downloader manifest configuration breaking android support ([f3a0f78](https://github.com/KRTirtho/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) -- login screen not using safearea and no dialog bg-color found on light mode in AdaptivePopupMenuButton ([92bc611](https://github.com/KRTirtho/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) -- **performance:** always running marquee text causes high GPU usage [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow on smaller displays ([a23ce61](https://github.com/KRTirtho/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) -- **playback:** shuffle button sometimes gets stuck and stops working [#183](https://github.com/KRTirtho/spotube/issues/183) ([4240433](https://github.com/KRTirtho/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) -- **player-overlay:** flickering when a track is changed or navigated to another page ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) -- **sidebar:** user image url ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) -- **synced-lyrics:** active lyrics contrast ratio ([aba1ba9](https://github.com/KRTirtho/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) -- tabbar overflow in small screen, artist card too small title and synced lyrics contrast increased ([585de8c](https://github.com/KRTirtho/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) +- **adaptive-list-tile:** dialog content not updating when content has changed + ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) +- album & playlist card, player view and album view play button logic + ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) +- **docs:** indentions + ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) +- **downloader:** downloaded track is corrupted for tagging + ([2ab1fba](https://github.com/KRTirtho/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) +- **downloader:** flutter downloader exception on desktop platform and too much + width of TrackTile index no. + ([d668760](https://github.com/KRTirtho/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) +- dropped flutter_downloader deps due to slow download speed and UserDownloads + not showing for anonymous + ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) +- flutter_downloader manifest configuration breaking android support + ([f3a0f78](https://github.com/KRTirtho/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) +- login screen not using safearea and no dialog bg-color found on light mode in + AdaptivePopupMenuButton + ([92bc611](https://github.com/KRTirtho/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) +- **performance:** always running marquee text causes high GPU usage + [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow + on smaller displays + ([a23ce61](https://github.com/KRTirtho/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) +- **playback:** shuffle button sometimes gets stuck and stops working + [#183](https://github.com/KRTirtho/spotube/issues/183) + ([4240433](https://github.com/KRTirtho/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) +- **player-overlay:** flickering when a track is changed or navigated to another + page + ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) +- **sidebar:** user image url + ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) +- **synced-lyrics:** active lyrics contrast ratio + ([aba1ba9](https://github.com/KRTirtho/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) +- tabbar overflow in small screen, artist card too small title and synced lyrics + contrast increased + ([585de8c](https://github.com/KRTirtho/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) # v2.3.0 ### New -- Playback Cache Support. So unfinished playlist and tracks remains cached & starts automatically when application is launched again +- Playback Cache Support. So unfinished playlist and tracks remains cached & + starts automatically when application is launched again - Login Screen guided tutorial about how to obtain Client ID & Client Secret -- Signed Android Application so now longer need to uninstall the old version for installing the new one +- Signed Android Application so now longer need to uninstall the old version for + installing the new one - OS Media controls for Linux. Keyboard media keys now work in Linux -- New better, consistent & predictable Audio engine with proper event firing support (https://github.com/KRTirtho/spotube/pull/131) +- New better, consistent & predictable Audio engine with proper event firing + support (https://github.com/KRTirtho/spotube/pull/131) - Custom Lyrics delay time. Can be used to delay negative amount of time too -- Playback Queue View support. Currently playing tracks or playlist can be viewed or changed from it or for doing other actions too (https://github.com/KRTirtho/spotube/issues/126) +- Playback Queue View support. Currently playing tracks or playlist can be + viewed or changed from it or for doing other actions too + (https://github.com/KRTirtho/spotube/issues/126) - Android SeekBar support in Notification Panel & Lock Screen -- New Blur background design adapted to multiple components including Floating Player, Player View & Lyrics Tab -- New HighContrast Color Scheme addition which reduces battery consumption on OLED or AMOLED display devices (https://github.com/KRTirtho/spotube/issues/137) +- New Blur background design adapted to multiple components including Floating + Player, Player View & Lyrics Tab +- New HighContrast Color Scheme addition which reduces battery consumption on + OLED or AMOLED display devices + (https://github.com/KRTirtho/spotube/issues/137) ### Improved - Loading screens & animations. Now uses Skeleton Loading -- Playlist & Album Pages now show Album Art & extra metadata as Header with vibrant gradient background in a Sliver -- Playback is now more consistent & the API is simpler. Also its the single source of truth for AudioPlayback instead of the AudioServiceHandler +- Playlist & Album Pages now show Album Art & extra metadata as Header with + vibrant gradient background in a Sliver +- Playback is now more consistent & the API is simpler. Also its the single + source of truth for AudioPlayback instead of the AudioServiceHandler - Android Statusbar background color is now adaptive & less glitchy -- Home Genre playlists can be scrolled horizontally by dragging with mouse even in Desktop edition -- Track match Cache support for previously played tracks. This dramatically reduces track change latency & load on the YouTube search engine too +- Home Genre playlists can be scrolled horizontally by dragging with mouse even + in Desktop edition +- Track match Cache support for previously played tracks. This dramatically + reduces track change latency & load on the YouTube search engine too ### Bug Fixes - API rate limits inside TrackTile for multiple Follow queries at once - Player doesn't stop when Application is exits or closed - First Track of Playlist doesn't load sometimes -- Download Button doesn't show done symbol when track is already saved (https://github.com/KRTirtho/spotube/issues/138) -- Downloaded Music is 0kb sized when lyrics are downloaded alongside (https://github.com/KRTirtho/spotube/issues/122) +- Download Button doesn't show done symbol when track is already saved + (https://github.com/KRTirtho/spotube/issues/138) +- Downloaded Music is 0kb sized when lyrics are downloaded alongside + (https://github.com/KRTirtho/spotube/issues/122) # v2.2.1 @@ -957,14 +1716,17 @@ - Update checker - Share options for playlists & track -- Android Skip to Next/Previous track from notification/lockscreen (https://github.com/KRTirtho/spotube/issues/91) +- Android Skip to Next/Previous track from notification/lockscreen + (https://github.com/KRTirtho/spotube/issues/91) - Custom Accent Color Scheme support (Dark + Light) - Custom Background Color Scheme support (Dark + Light) - User customizable Audio Quality Option - User customizable Track Matching Algorithm Option - Material 3 Design Language and Flutter 3.0 -- Caching in Playlists, Album, Search, Playlist Categories, Artist Profile & Lyrics -- M1 Mac support via MacOS Universal Binary (untested) (https://github.com/KRTirtho/spotube/pull/87) +- Caching in Playlists, Album, Search, Playlist Categories, Artist Profile & + Lyrics +- M1 Mac support via MacOS Universal Binary (untested) + (https://github.com/KRTirtho/spotube/pull/87) ### Improved @@ -974,7 +1736,8 @@ ### Bug fixes -- private playlists of current user aren't shown fix (https://github.com/KRTirtho/spotube/issues/92) +- private playlists of current user aren't shown fix + (https://github.com/KRTirtho/spotube/issues/92) - refresh token error causing re-login (culprit: internal lib spotify-dart) - Typo in Login instructions URL @@ -995,13 +1758,17 @@ - Genius lyrics matching algorithm - Download track. Checks if already exists & replaces on user command - Wide screen responsiveness & adaptation -- Bigger Title display (replaced word-break with Marquee Text for better visibility) (https://github.com/KRTirtho/spotube/pull/47) +- Bigger Title display (replaced word-break with Marquee Text for better + visibility) (https://github.com/KRTirtho/spotube/pull/47) ### Bug fixes -- Sequential playlist playback not working with latest webkit2gtk (https://github.com/KRTirtho/spotube/issues/46) -- Theme modification state doesn't persist (https://github.com/KRTirtho/spotube/issues/54) -- Wrong URI path for "Login with Spotify" tutorial (https://github.com/KRTirtho/spotube/issues/69) +- Sequential playlist playback not working with latest webkit2gtk + (https://github.com/KRTirtho/spotube/issues/46) +- Theme modification state doesn't persist + (https://github.com/KRTirtho/spotube/issues/54) +- Wrong URI path for "Login with Spotify" tutorial + (https://github.com/KRTirtho/spotube/issues/69) - Card shadow showing in the background of TitleBar & Searchbar # v2.0.0 @@ -1023,7 +1790,8 @@ - Search - now scrolls & paginates for Playlists & Albums - Authentication - allows guest accounts making authentication optional -- Lyrics - can be fetched without requiring GeniusAccessToken. This makes geniusAccessToken optional +- Lyrics - can be fetched without requiring GeniusAccessToken. This makes + geniusAccessToken optional - UI snappiness & faster load times - Simpler logic, faster calculations & better caching (flutter_hooks) - shared state management - uses riverpod & hooks combination @@ -1038,7 +1806,8 @@ ### New -- Global custom reconfigurable _hotkey_ support for playback controls (play-pause/next/previous) +- Global custom reconfigurable _hotkey_ support for playback controls + (play-pause/next/previous) - Credit section in the Settings page with important links ### Improved @@ -1049,7 +1818,8 @@ ### Bug fixes -- broken authentication IPC on Mac OS (https://github.com/KRTirtho/spotube/pull/18) +- broken authentication IPC on Mac OS + (https://github.com/KRTirtho/spotube/pull/18) - Mac OS's global appmenu's default APP_NAME replaced with Spotube - location of back button on macOS (https://github.com/KRTirtho/spotube/pull/21) - windows titlebar buttons appears on Mac OS @@ -1060,7 +1830,8 @@ ### New - MacOS support https://github.com/KRTirtho/spotube/pull/7 -- Download currently playing track to `/home//Downloads/Spotube` (Linux, MacOS) or `C:\Users\\Downloads\Spotube` (Windows) +- Download currently playing track to `/home//Downloads/Spotube` (Linux, + MacOS) or `C:\Users\\Downloads\Spotube` (Windows) - Play playlist from any song (index) instead of only the first track - AlbumCard for showing album's metadata - AlbumView aka show album tracks @@ -1082,8 +1853,10 @@ ### Bug fixes -- Unsafe access to album art/artist/user Images with `.first` or `.last` causing accessing empty List error -- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain \*nix OSs +- Unsafe access to album art/artist/user Images with `.first` or `.last` causing + accessing empty List error +- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain + \*nix OSs - Refresh token gets revoked & doesn't get renewed automatically # v1.0.1 @@ -1103,7 +1876,8 @@ - Complete re-write in Flutter/Dart (799e13c) - mpv & youtube-dl runtime dependencies dropped (07b1891) -- just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based playback & streaming +- just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based + playback & streaming - lyrics are provided by genius.com (requires access_token) (d647d5e) - inno_setup based windows/win32 GUI installer (dbf8a34) @@ -1119,7 +1893,8 @@ ### New -- Automated installer for Windows (now doesn't require manual mpv-player install) +- Automated installer for Windows (now doesn't require manual mpv-player + install) - Playback caching - Retry button for ManualLyricDialog - Support for downloading track @@ -1127,11 +1902,13 @@ ### Improved -- Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search page +- Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search + page ### Bug fixes -- Cached image didn't get deleted after exiting certain cache limit fix. Cache gets recreated after exiting the limit +- Cached image didn't get deleted after exiting certain cache limit fix. Cache + gets recreated after exiting the limit # v0.0.2 @@ -1148,17 +1925,20 @@ ### Bug Fixes -- `access_token not found` Error after OAuth Login with Spotify credentials (used to need a restart of the app to load the access_token) +- `access_token not found` Error after OAuth Login with Spotify credentials + (used to need a restart of the app to load the access_token) - Volume level wasn't cached even after changing volume # v0.0.1 -Spotube v0.0.1 - initial release of the open source software for playing Spotify music using Youtube public API +Spotube v0.0.1 - initial release of the open source software for playing Spotify +music using Youtube public API ### New - Local playback handling - Playback Queue - Save to Liked Tracks/Playlists -- Bypass API rate limitation on basic usage using personal developer Apps for spotify API +- Bypass API rate limitation on basic usage using personal developer Apps for + spotify API - Youtube search & get handled using scrape-yt diff --git a/lib/models/metadata/image.dart b/lib/models/metadata/image.dart index 2ee0f748..9a4ee026 100644 --- a/lib/models/metadata/image.dart +++ b/lib/models/metadata/image.dart @@ -33,7 +33,8 @@ extension SpotubeImageExtensions on List? { int index = 1, required ImagePlaceholder placeholder, }) { - final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); + final sortedImage = + this?.sorted((a, b) => (a.width ?? 0).compareTo(b.width ?? 0)); return sortedImage != null && sortedImage.isNotEmpty ? sortedImage[ diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index cb4f4a0b..da5cc0e2 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -59,6 +59,7 @@ class SearchPage extends HookConsumerWidget { void onSubmitted(String value) { ref.read(searchTermStateProvider.notifier).state = value; + focusNode.unfocus(); if (value.trim().isEmpty) { return; } @@ -127,61 +128,47 @@ class SearchPage extends HookConsumerWidget { ) .toList(); - return KeyboardListener( - focusNode: focusNode, - autofocus: true, - onKeyEvent: (value) { - final isEnter = value.logicalKey == - LogicalKeyboardKey.enter; - - if (isEnter) { - onSubmitted(controller.text); - focusNode.unfocus(); - } - }, - child: AutoComplete( - suggestions: suggestions.length <= 2 - ? [ - ...suggestions, - "Twenty One Pilots", - "Linkin Park", - "d4vd" - ] - : suggestions, - completer: (suggestion) => suggestion, - mode: AutoCompleteMode.replaceAll, - child: TextField( - autofocus: true, - controller: controller, - features: [ - const InputFeature.leading( - Icon(SpotubeIcons.search), - ), - InputFeature.trailing( - AnimatedCrossFade( - duration: - const Duration(milliseconds: 300), - crossFadeState: - controller.text.isNotEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: IconButton.ghost( - size: ButtonSize.small, - icon: - const Icon(SpotubeIcons.close), - onPressed: () { - controller.clear(); - }, - ), - secondChild: const SizedBox.square( - dimension: 28), + return AutoComplete( + suggestions: suggestions.length <= 2 + ? [ + ...suggestions, + "Twenty One Pilots", + "Linkin Park", + ] + : suggestions, + completer: (suggestion) => suggestion, + mode: AutoCompleteMode.replaceAll, + child: TextField( + autofocus: true, + controller: controller, + focusNode: focusNode, + features: [ + const InputFeature.leading( + Icon(SpotubeIcons.search), + ), + InputFeature.trailing( + AnimatedCrossFade( + duration: + const Duration(milliseconds: 300), + crossFadeState: + controller.text.isNotEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: IconButton.ghost( + size: ButtonSize.small, + icon: const Icon(SpotubeIcons.close), + onPressed: () { + controller.clear(); + }, ), - ) - ], - textInputAction: TextInputAction.search, - placeholder: Text(context.l10n.search), - onSubmitted: onSubmitted, - ), + secondChild: const SizedBox.square( + dimension: 28), + ), + ) + ], + textInputAction: TextInputAction.search, + placeholder: Text(context.l10n.search), + onSubmitted: onSubmitted, ), ); }), diff --git a/lib/services/youtube_engine/newpipe_engine.dart b/lib/services/youtube_engine/newpipe_engine.dart index d6445a19..c865b66c 100644 --- a/lib/services/youtube_engine/newpipe_engine.dart +++ b/lib/services/youtube_engine/newpipe_engine.dart @@ -28,6 +28,26 @@ class NewPipeEngine implements YouTubeEngine { ); } + AudioOnlyStreamInfo _parseVideoStream(VideoStream stream, String videoId) { + return AudioOnlyStreamInfo( + VideoId(videoId), + stream.itag, + Uri.parse(stream.content), + StreamContainer.parse(stream.mediaFormat!.mimeType.split("/").last), + FileSize.unknown, + Bitrate(stream.bitrate), + stream.codec, + switch (stream.bitrate) { + > 130 * 1024 => "high", + > 64 * 1024 => "medium", + _ => "low", + }, + [], + MediaType.parse(stream.mediaFormat!.mimeType), + null, + ); + } + Video _parseVideo(VideoInfo info) { return Video( VideoId(info.id), @@ -76,6 +96,14 @@ class NewPipeEngine implements YouTubeEngine { final streams = video.audioStreams.map((stream) => _parseAudioStream(stream, videoId)); + if (streams.isEmpty) { + final videoStreams = video.videoStreams + .map((stream) => _parseVideoStream(stream, videoId)); + if (videoStreams.isNotEmpty) { + return StreamManifest(videoStreams); + } + } + return StreamManifest(streams); } @@ -93,6 +121,14 @@ class NewPipeEngine implements YouTubeEngine { final streams = video.audioStreams.map((stream) => _parseAudioStream(stream, videoId)); + if (streams.isEmpty) { + final videoStreams = video.videoStreams + .map((stream) => _parseVideoStream(stream, videoId)); + if (videoStreams.isNotEmpty) { + return (_parseVideo(video), StreamManifest(videoStreams)); + } + } + return (_parseVideo(video), StreamManifest(streams)); } diff --git a/pubspec.lock b/pubspec.lock index dd5a987e..81372cdd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -486,10 +486,10 @@ packages: dependency: transitive description: name: data_widget - sha256: "95388df890189014f702b7e93f9de6bcf7d45143a99f6288f31899f10be441ba" + sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a" url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.3" dbus: dependency: transitive description: @@ -947,7 +947,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: ab3ff415114b7b43593e6ee718ad3d760af18350 + resolved-ref: "41cbd821ce49b9343136f67d83d3117e582dad75" url: "https://github.com/KRTirtho/flutter_new_pipe_extractor" source: git version: "0.1.0" @@ -2728,10 +2728,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "3d731d71df9901b1915bae806781df519cff32517e36db279f844ae619669e45" + sha256: "10134a53989b2f3dc576121735aec8fc6d88784956f4a2ad1a2525b006373a76" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.1.0" yt_dlp_dart: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7941c625..dd91f204 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source extensible music streaming platform and app, based on B publish_to: "none" -version: 5.1.1+44 +version: 5.1.2+45 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -90,7 +90,7 @@ dependencies: ref: dart-3-support url: https://github.com/KRTirtho/scrobblenaut.git scroll_to_index: ^3.0.1 - shadcn_flutter: ^0.0.47 + shadcn_flutter: 0.0.47 shared_preferences: ^2.2.3 shelf: ^1.4.1 shelf_router: ^1.1.4 @@ -114,7 +114,7 @@ dependencies: wikipedia_api: ^0.1.0 win32_registry: ^1.1.5 window_manager: ^0.4.3 - youtube_explode_dart: ^3.0.5 + youtube_explode_dart: ^3.1.0 yt_dlp_dart: git: url: https://github.com/KRTirtho/yt_dlp_dart.git @@ -171,7 +171,7 @@ dev_dependencies: pub_api_client: ^3.0.0 io: ^1.0.4 drift_dev: ^2.21.0 - test: ^1.25.7 + test: any auto_route_generator: ^9.0.0 dependency_overrides: From d3a8548661fe41ca9d0e647a1c09ad43e607250f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 21 Aug 2026 19:50:09 +0600 Subject: [PATCH 002/132] feat(devices): add device discovery and remote control features --- .opencode/plans/jovial-hopping-hare.md | 821 ++++++++ composeApp/Cargo.lock | 1832 ++++++++++++++++- composeApp/Cargo.toml | 9 +- composeApp/build.gradle.kts | 6 + .../src/androidMain/AndroidManifest.xml | 8 +- .../composeResources/values/strings.xml | 7 + .../dev/krtirtho/spotube/core/di/Modules.kt | 14 + .../core/discovery/DeviceDiscoveryService.kt | 100 + .../krtirtho/spotube/core/jam/JamProtocol.kt | 178 ++ .../spotube/core/jam/JamSessionService.kt | 316 +++ .../spotube/core/jam/QueueSyncManager.kt | 162 ++ .../core/navigation/NavigationModule.kt | 14 + .../core/navigation/NavigationState.kt | 2 + .../core/remote/ConnectionRequestDialog.kt | 91 + .../core/remote/RemoteControlHandler.kt | 262 +++ .../core/remote/RemoteControlProtocol.kt | 113 + .../core/remote/RemoteControlService.kt | 104 + .../spotube/core/server/LocalServer.kt | 45 +- .../spotube/modules/devices/DevicesScreen.kt | 181 ++ .../modules/devices/DevicesViewModel.kt | 79 + .../spotube/modules/home/HomeScreen.kt | 25 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 227 ++ .../spotube/modules/jam/JamViewModel.kt | 85 + .../modules/jam/PlayDestinationPicker.kt | 63 + .../modules/settings/SettingsModels.kt | 9 + .../settings/sections/PlaybackSection.kt | 41 + .../spotube/modules/shell/AppShell.kt | 3 + .../spotube/modules/shell/AppSidebar.kt | 19 + composeApp/src/commonMain/rust/lib.rs | 4 +- composeApp/src/commonMain/rust/webrtc_p2p.rs | 289 +++ gradle/libs.versions.toml | 4 + iosApp/iosApp/Info.plist | 7 + 32 files changed, 5096 insertions(+), 24 deletions(-) create mode 100644 .opencode/plans/jovial-hopping-hare.md create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt create mode 100644 composeApp/src/commonMain/rust/webrtc_p2p.rs diff --git a/.opencode/plans/jovial-hopping-hare.md b/.opencode/plans/jovial-hopping-hare.md new file mode 100644 index 00000000..bad5a743 --- /dev/null +++ b/.opencode/plans/jovial-hopping-hare.md @@ -0,0 +1,821 @@ +# WebRTC Support for Group Jam & Remote Control + +## Overview +Add two peer-to-peer features to Spotube: +1. **Listen Together (Group Jam)**: Multi-user synced queue over WebRTC data channels (star topology, manual SDP exchange) +2. **Remote Control**: LAN-only device control via WebSocket on the existing `LocalServer` (extended with control routes). No WebRTC needed for this feature. + +Both features share UI patterns (adaptive dialogs for play interception) but use different transport layers based on their requirements. + +--- + +## Prerequisites (One-Time Setup) + +Before starting implementation: + +1. **Initialize webrtc-rs submodule**: + ```bash + cd build/webrtc-rs && git submodule update --init --recursive + ``` + The `rtc` crate (Sans-I/O core) is a git submodule and must be initialized before building. + +2. **Verify dns-sd-kt availability**: + - Published to Maven Central: `com.appstractive:dns-sd-kt:1.1.0` + - No setup needed; just add to `libs.versions.toml` + +3. **Verify Rust toolchain**: + - Existing uniffi setup already works (discord-rpc, metadata modules) + - Ensure `cargo` is available and can build for all targets + +--- + +## Architecture Decisions (Confirmed) + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| WebRTC implementation | `webrtc-rs` via uniffi | Single codebase, identical behavior across platforms | +| Jam topology | Star (host ↔ peers) | Simpler, scales better, matches host-authority model | +| Remote Control transport | TCP/WebSocket only | LAN-only, so WebRTC is overkill; direct connection is simpler | +| Jam signaling | Manual SDP exchange | No server infrastructure needed; users copy-paste or scan QR | + +--- + +## Phase 0: Rust Uniffi WebRTC Module + +### Goal +Add `webrtc-rs` to the existing Rust crate and expose a uniffi API for WebRTC peer connections and data channels. + +### Library Details (from `build/webrtc-rs`) +- **Crate**: `webrtc` v0.21.0-beta.1 (pure Rust, no external C/C++ libs) +- **Architecture**: Sans-I/O core (`rtc` crate) + async API layer +- **Async runtime**: tokio (default) or smol +- **Crypto**: `ring` (default) or `aws-lc-rs` +- **Key types**: + - `PeerConnection` (trait) — created via `PeerConnectionBuilder::build()` + - `DataChannel` (trait) — created via `peer.create_data_channel()` + - `RTCSessionDescription` — SDP offer/answer + - `RTCIceCandidateInit` — ICE candidates + - `PeerConnectionEventHandler` (trait) — callback interface for events + - `DataChannelEvent` (enum) — polled via `dc.poll().await` +- **Event model**: PeerConnection uses callbacks; DataChannel uses polling +- **Submodule**: `rtc` git submodule must be initialized before building + +### Files to Modify +- `composeApp/Cargo.toml` — add `webrtc` dependency +- `composeApp/src/commonMain/rust/lib.rs` — register new module +- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API + +### Implementation + +1. **Initialize webrtc-rs submodule** (one-time setup): + ```bash + cd build/webrtc-rs && git submodule update --init --recursive + ``` + +2. **Add webrtc-rs dependency** to `composeApp/Cargo.toml`: + ```toml + [dependencies] + webrtc = { path = "../build/webrtc-rs", features = ["runtime-tokio", "crypto-ring"] } + tokio = { version = "1", features = ["full"] } + async-trait = "0.1" + ``` + + **Note**: Using path dependency to the local clone. For production, switch to crates.io version once stable. + +3. **Define uniffi API** in `webrtc_p2p.rs`: + + **Core objects**: + ```rust + #[uniffi::export] + pub struct PeerConnectionWrapper { + pc: Arc, + runtime: Arc, + } + + #[uniffi::export] + impl PeerConnectionWrapper { + pub async fn create_offer(&self) -> Result { + let offer = self.pc.create_offer(None).await?; + Ok(offer.sdp) + } + + pub async fn set_remote_answer(&self, answer: String) -> Result<(), WebrtcError> { + let desc = RTCSessionDescription::answer(answer)?; + self.pc.set_remote_description(desc).await?; + Ok(()) + } + + pub async fn create_answer(&self) -> Result { + let answer = self.pc.create_answer(None).await?; + Ok(answer.sdp) + } + + pub async fn set_remote_offer(&self, offer: String) -> Result<(), WebrtcError> { + let desc = RTCSessionDescription::offer(offer)?; + self.pc.set_remote_description(desc).await?; + Ok(()) + } + + pub async fn send_data(&self, channel: String, data: String) -> Result<(), WebrtcError> { + // Find or cache data channel by label + // ... + Ok(()) + } + + pub async fn close(&self) -> Result<(), WebrtcError> { + self.pc.close().await?; + Ok(()) + } + } + ``` + + **Callback interface for events**: + ```rust + #[uniffi::export(callback_interface)] + pub trait PeerConnectionEventHandler { + fn on_ice_candidate(&self, candidate: String); + fn on_connection_state_change(&self, state: String); + fn on_data_channel(&self, label: String); + fn on_data_channel_message(&self, label: String, data: String); + } + ``` + + **Factory function**: + ```rust + #[uniffi::export] + pub async fn create_peer_connection( + ice_servers: Vec, + handler: Arc, + ) -> Result { + // Build RTCConfiguration from ice_servers + // Create MediaEngine, Registry + // Build PeerConnection with handler wrapper + // Spawn task to poll data channel events and forward to handler + Ok(PeerConnectionWrapper { pc, runtime }) + } + ``` + + **Key challenge**: webrtc-rs is fully async, but uniffi callbacks are synchronous. Solution: + - Wrap the `PeerConnectionEventHandler` trait in a Rust adapter that spawns async tasks + - Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks + - For DataChannel polling, spawn a background task that calls `dc.poll().await` in a loop and forwards messages to the Kotlin handler + +4. **Register module** in `lib.rs`: + ```rust + mod webrtc_p2p; + pub use webrtc_p2p::*; + ``` + +5. **Cross-compilation considerations**: + - **Good news**: webrtc-rs is pure Rust (no libwebrtc/BoringSSL C++ deps) + - **Crypto**: `ring` compiles from source for all targets (requires C compiler for Android/iOS) + - **JVM desktop**: Should work out of the box + - **Android**: Requires NDK + `ring` cross-compilation setup (well-supported) + - **iOS**: Requires `ring` cross-compilation for aarch64-apple-ios + - **Gobley plugin**: Already configured for multi-target Rust builds in `composeApp/build.gradle.kts` + +### Verification +- Initialize submodule: `cd build/webrtc-rs && git submodule update --init --recursive` +- Build Rust crate: `cargo build --release` in `composeApp/` +- Verify Kotlin bindings are generated in `uniffi.compose_app.*` +- Write a simple Kotlin test that creates a peer connection and exchanges SDP + +--- + +## Phase 1: Remote Control (LAN-only, extend existing LocalServer) + +### Goal +Allow users to control playback on another device on the same LAN. Opt-in via settings. DNS-SD for discovery (via dns-sd-kt). Extend the existing `LocalServer` with WebSocket routes for control commands — no separate server needed. + +### 1.1 Settings & Permissions + +#### Files to Modify +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` — add fields to `UserSettings` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` — add toggle UI +- `composeApp/src/commonMain/composeResources/values/strings.xml` — add strings +- `composeApp/src/androidMain/AndroidManifest.xml` — add permissions +- `iosApp/iosApp/Info.plist` — add Bonjour services + +#### Implementation +1. **Add to `UserSettings`**: + ```kotlin + val allowRemoteControl: Boolean = false, + val allowedRemoteDevices: List = emptyList(), // device IDs + ``` + +2. **Add toggle UI** in `PlaybackSection.kt`: + - Use `SwitchSettingCard` for "Allow remote control" + - Add a "Manage allowed devices" item that navigates to a sub-screen (see `Routes.Blacklist` pattern) + +3. **Add string resources**: + ```xml + Allow Remote Control + Let other devices on your network control playback + ``` + +4. **Android permissions** (dns-sd-kt requires these): + ```xml + + + + + + + + + ``` + + **Note**: dns-sd-kt uses `androidx.startup` to auto-initialize `Context` — no manual init needed. + +5. **iOS Info.plist** (add to `iosApp/iosApp/Info.plist`): + ```xml + NSLocalNetworkUsageDescription + Spotube needs access to your local network to discover and control other devices. + NSBonjourServices + + _spotube-ctrl._tcp + + ``` + + **Note**: dns-sd-kt's Apple backend uses `NWBrowser` (Network.framework) + custom Swift bridge. The `NSBonjourServices` key is required for Bonjour discovery to work. + +### 1.2 DNS-SD Discovery + +#### Library Details (from `build/dns-sd-kt`) +- **Maven Central**: `com.appstractive:dns-sd-kt:1.1.0` +- **KMP library**: supports Android, JVM, iOS (arm64 + simulatorArm64), macOS, tvOS +- **Fully coroutine/Flow-based** — no callback-style API +- **Platform backends**: + - Android: `NsdManager` (pure Kotlin) + - JVM: `JmDNS 3.6.3` (pure Java) + - Apple: `NWBrowser` + `NSNetService` + custom Swift bridge via `spm4kmp` +- **Two-phase resolution**: `DiscoveryEvent.Discovered` → call `resolve()` → `DiscoveryEvent.Resolved` with addresses +- **Auto-init on Android**: uses `androidx.startup` to grab `Context` + +#### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — wraps dns-sd-kt APIs + +#### Files to Modify +- `gradle/libs.versions.toml` — add dns-sd-kt dependency +- `composeApp/build.gradle.kts` — add to commonMain dependencies + +#### Implementation + +1. **Add dns-sd-kt dependency** to `gradle/libs.versions.toml`: + ```toml + [versions] + dns-sd-kt = "1.1.0" + + [libraries] + dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dns-sd-kt" } + ``` + +2. **Add to `composeApp/build.gradle.kts`** in `commonMain.dependencies`: + ```kotlin + implementation(libs.dns.sd.kt) + ``` + +3. **Create `DeviceDiscoveryService`** in `commonMain` (no expect/actual needed — dns-sd-kt handles platform differences): + ```kotlin + class DeviceDiscoveryService { + private val serviceType = "_spotube-ctrl._tcp" + + fun discoverDevices(): Flow = discoverServices(serviceType) + + suspend fun registerDevice(deviceId: String, deviceName: String, port: Int): NetService { + val service = createNetService( + type = serviceType, + name = deviceName, + port = port, + txt = mapOf("deviceId" to deviceId), + ) + service.register() + return service + } + } + ``` + +4. **Usage in ViewModel**: + ```kotlin + // Discover devices + discoveryService.discoverDevices() + .onEach { event -> + when (event) { + is DiscoveryEvent.Discovered -> { + event.resolve() // trigger address resolution + // Add to discovered devices list (addresses may be empty) + } + is DiscoveryEvent.Resolved -> { + // Update with resolved addresses/host + } + is DiscoveryEvent.Removed -> { + // Remove from list + } + } + } + .launchIn(viewModelScope) + ``` + +5. **Register in Koin** in `Modules.kt`: + ```kotlin + single { DeviceDiscoveryService() } + ``` + +6. **No expect/actual needed** — dns-sd-kt is a KMP library that handles platform differences internally. The Apple targets use Swift interop via `spm4kmp`, which is transparent to consumers. + +### 1.3 Control Server (Extend LocalServer) + +#### Decision: Reuse Existing LocalServer +The app already has a Ktor CIO-based `LocalServer` running on `127.0.0.1:` for the playback proxy. We'll extend it with WebSocket routes for control commands. When remote control is enabled, the server binds to `0.0.0.0` (LAN-accessible); otherwise it stays on `127.0.0.1` (local-only). + +#### Files to Modify +- `gradle/libs.versions.toml` — add `ktor-server-websockets`, `ktor-client-websockets` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — add WebSocket routes, conditional bind to `0.0.0.0` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt` — update LocalServer registration + +#### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — handles control messages +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — message definitions +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — wraps AudioPlayerInterface for remote control + +#### Implementation +1. **Add WebSocket dependencies** to `libs.versions.toml`: + ```toml + ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } + ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } + ``` + +2. **Extend `LocalServer.kt`**: + - Add `RemoteControlHandler` constructor parameter + - In `configureRoutes()`, install `WebSockets` plugin and add `/control` WebSocket route + - In `restartServer()`, check `settings.allowRemoteControl`: + - If enabled: bind to `0.0.0.0` (LAN-accessible) + - If disabled: bind to `127.0.0.1` (local-only, current behavior) + - Add a watcher that restarts the server when `allowRemoteControl` setting changes + + ```kotlin + private suspend fun restartServer(port: Int) { + val allowRemoteControl = settingsViewModel.settingsState.value?.allowRemoteControl ?: false + val host = if (allowRemoteControl) "0.0.0.0" else "127.0.0.1" + + serverState.value = embeddedServer( + factory = CIO, + host = host, + port = port, + module = { configureRoutes() } + ).also { engine -> + engine.start(wait = false) + } + } + + private fun Application.configureRoutes() { + install(WebSockets) + routing { + get("/health") { call.respondText("ok") } + // ... existing routes ... + + webSocket("/control") { + remoteControlHandler.handleConnection(this) + } + } + } + ``` + +3. **Define protocol** in `RemoteControlProtocol.kt`: + ```kotlin + @Serializable + sealed class RemoteControlMessage { + @Serializable data class Play(val trackId: String) : RemoteControlMessage() + @Serializable data class Pause(val unit: Unit = Unit) : RemoteControlMessage() + @Serializable data class Seek(val positionMs: Long) : RemoteControlMessage() + @Serializable data class SetVolume(val volume: Float) : RemoteControlMessage() + @Serializable data class AddToQueue(val trackId: String) : RemoteControlMessage() + // ... etc + } + + @Serializable + sealed class RemoteStateUpdate { + @Serializable data class PlayerState(val state: PlayerUiState) : RemoteStateUpdate() + @Serializable data class QueueUpdate(val queue: List) : RemoteStateUpdate() + } + ``` + +4. **Create `RemoteControlHandler`**: + - Handles incoming WebSocket connections on the controlled device + - Checks `settings.allowRemoteControl` before accepting (rejects immediately if disabled) + - Shows connection request dialog (allow/allow-always/deny) via a callback injected from the UI layer + - On acceptance: forwards commands to `AudioPlayerInterface` and `AudioPlayerQueue` + - Broadcasts state updates (player state, queue) to the connected controller + +5. **Create `RemotePlayerProxy`**: + - Wraps `AudioPlayerInterface` and `AudioPlayerQueue` on the controlling device + - Sends commands over WebSocket to the controlled device + - Receives state updates and exposes them as StateFlows + - **Implementation note**: Full interface implementation is complex. Alternative: create a separate `RemotePlayerState` StateFlow that mirrors remote state, and the UI uses it instead of `rememberPlayerUiState()`. + +6. **Register in Koin** in `Modules.kt`: + ```kotlin + single { RemoteControlHandler(get(), get(), get()) } + // LocalServer constructor updated; no other DI changes needed + ``` + +### Key Simplification +By reusing `LocalServer`, we eliminate the need for: +- A separate WebSocket server +- Separate port management +- Duplicate Ktor configuration + +The server becomes a multi-purpose local server: playback proxy (always) + control endpoint (when enabled). + +### 1.4 UI: Devices Screen + +#### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` + +#### Files to Modify +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Devices` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Devices" button at bottom +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Devices" icon to TopAppBar actions + +#### Implementation +1. **Add route** to `NavigationModule.kt`: + ```kotlin + @Serializable + data object Devices : Routes + + navigation { + DevicesScreen(...) + } + ``` + +2. **Add sidebar button** in `AppSidebar.kt` (after line 177): + ```kotlin + SidebarItem( + title = "Devices", + icon = Icons.Default.Devices, + onClick = { navigator.navigate(Routes.Devices) } + ) + ``` + +3. **Add TopAppBar action** in `HomeScreen.kt`: + ```kotlin + ApplicationMainBar( + actions = { + IconButton(onClick = { navigator.navigate(Routes.Devices) }) { + Icon(Icons.Default.Devices, "Devices") + } + } + ) + ``` + +4. **DevicesScreen**: + - Shows list of discovered devices (from `DeviceDiscoveryService`) + - Each device shows name, IP, and connection status + - Clicking a device initiates connection (WebSocket) + - After connection, navigates to `RemotePlayerScreen` + +5. **RemotePlayerScreen**: + - Similar to `AppExpandedPlayer` but uses `RemotePlayerProxy` instead of local `AudioPlayerInterface` + - All controls (play/pause/seek/volume/queue) forward to remote device + +### 1.5 Connection Request Flow + +#### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/remote/ConnectionRequestDialog.kt` + +#### Implementation +1. When a new device tries to connect, `RemoteControlService` shows a dialog: + ```kotlin + AdaptiveDialogBottomSheet( + title = { Text("Remote Control Request") }, + content = { + Text("Device '${deviceName}' wants to control playback") + }, + actions = { + Button(onClick = { deny() }) { Text("Deny") } + Button(onClick = { allow(always = false) }) { Text("Allow") } + Button(onClick = { allow(always = true) }) { Text("Allow Always") } + } + ) + ``` + +2. If "Allow Always", add device ID to `settings.allowedRemoteDevices` + +--- + +## Phase 2: Group Jam (WebRTC, Manual SDP) + +### Goal +Multi-user synced queue over WebRTC data channels. Host creates session, shares SDP offer (via copy-paste or QR), guests join. Star topology (host ↔ peers). + +### 2.1 Jam Session Service + +#### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — manages WebRTC connections +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt` — message definitions +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt` — syncs queue state + +#### Implementation +1. **Define protocol** in `JamProtocol.kt`: + ```kotlin + @Serializable + sealed class JamMessage { + // Host → Peers + @Serializable data class QueueState(val queue: List, val currentIndex: Int) : JamMessage() + @Serializable data class PlaybackCommand(val command: PlaybackCommand) : JamMessage() + @Serializable data class ParticipantList(val participants: List) : JamMessage() + + // Peers → Host + @Serializable data class SuggestTrack(val trackId: String) : JamMessage() + @Serializable data class SuggestPlaylist(val playlistId: String) : JamMessage() + @Serializable data class ChatMessage(val text: String) : JamMessage() + } + + data class Participant(val id: String, val name: String, val isHost: Boolean) + ``` + +2. **Create `JamSessionService`** using the uniffi WebRTC API from Phase 0: + ```kotlin + class JamSessionService( + private val audioPlayerQueue: AudioPlayerQueue, + private val audioPlayer: AudioPlayerInterface, + ) { + private var peerConnection: PeerConnectionWrapper? = null + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants + + suspend fun createSession(): String { + // Create peer connection with ICE servers + peerConnection = create_peer_connection( + iceServers = listOf("stun:stun.l.google.com:19302"), + handler = object : PeerConnectionEventHandler { + override fun on_ice_candidate(candidate: String) { + // ICE candidates are bundled into SDP (non-trickle mode) + } + override fun on_data_channel_message(label: String, data: String) { + // Parse JamMessage and handle + } + // ... other callbacks + } + ) + + // Create data channel for jam messages + // Create SDP offer and return it for sharing + val offer = peerConnection!!.create_offer() + return offer + } + + suspend fun joinSession(offer: String): String { + // Create peer connection + peerConnection = create_peer_connection(...) + + // Set remote offer and create answer + peerConnection!!.set_remote_offer(offer) + val answer = peerConnection!!.create_answer() + return answer + } + + suspend fun sendMessage(message: JamMessage) { + val json = Json.encodeToString(message) + peerConnection?.send_data("jam", json) + } + } + ``` + + **Key points**: + - Uses `uniffi.compose_app.create_peer_connection()` from Phase 0 + - Host creates multiple peer connections (one per guest) — star topology + - Data channel labeled "jam" for all jam messages + - SDP exchange is manual (copy-paste or QR code) + +3. **Create `QueueSyncManager`**: + - On host: wraps `AudioPlayerQueue`, intercepts all queue mutations, broadcasts them via `JamSessionService.sendMessage()` + - On guest: receives queue mutations, applies them to local queue + - Handles conflict resolution (host authority: host's commands always win) + +4. **Register in Koin**: + ```kotlin + single { JamSessionService(get(), get()) } + ``` + +### 2.2 Jam Session UI + +#### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — create/join session +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — active session view +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/SdpExchangeDialog.kt` — copy-paste SDP + +#### Files to Modify +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Jam` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Group Jam" button +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Group Jam" icon to TopAppBar + +#### Implementation +1. **Add route**: + ```kotlin + @Serializable + data object Jam : Routes + + navigation { + JamScreen(...) + } + ``` + +2. **Add sidebar button** (above "Devices"): + ```kotlin + SidebarItem( + title = "Group Jam", + icon = Icons.Default.Group, + onClick = { navigator.navigate(Routes.Jam) } + ) + ``` + +3. **JamScreen**: + - Two tabs: "Create Session" and "Join Session" + - **Create Session**: + - Generates SDP offer via `JamSessionService` + - Shows SDP as copyable text and QR code + - Waits for guests to connect + - **Join Session**: + - Text field to paste SDP offer + - QR code scanner (optional) + - Generates SDP answer and shows it for host to paste back + +4. **JamSessionScreen**: + - Shows list of participants (from `JamSessionService`) + - Shows current track and queue + - Playback controls (only work for host; guests send suggestions) + - Suggest track/playlist buttons + - Chat/messages area (optional) + +5. **SdpExchangeDialog**: + - Shows SDP string in a `TextField` (read-only for offer, editable for answer) + - "Copy" button + - "Paste" button (for answer) + - QR code display (using a QR generation library) + +### 2.3 Play Interception + +#### Files to Modify +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt` + +#### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt` + +#### Implementation +1. **Create `PlayDestinationPicker`**: + ```kotlin + @Composable + fun PlayDestinationPicker( + onPlayLocally: () -> Unit, + onSuggestToJam: () -> Unit, + onDismiss: () -> Unit + ) { + AdaptiveDialogBottomSheet( + title = { Text("Play Where?") }, + content = { + Column { + Button(onClick = onPlayLocally) { Text("Play on This Device") } + Button(onClick = onSuggestToJam) { Text("Suggest to Jam Session") } + } + }, + onDismiss = onDismiss + ) + } + ``` + +2. **Modify ViewModels**: + - In `PlaylistViewModel.playPlaylist()`, check if `JamSessionService.isActive` + - If active, show `PlayDestinationPicker` instead of calling `playbackHelper.playPlaylist()` directly + - If user chooses "Suggest to Jam", call `JamSessionService.suggestPlaylist(playlistId)` + +3. **Apply same pattern** to `AlbumViewModel`, `ArtistScreen`, `SearchScreen` + +--- + +## Phase 3: Deep-Link Handling (Optional Enhancement) + +### Goal +Allow users to open `spotube://jam/` links to join a Jam session. With manual SDP exchange, the deep link can contain a session ID + a short-lived token, and the actual SDP exchange happens in the app. + +### Files to Modify +- `composeApp/src/androidMain/AndroidManifest.xml` — add intent filter +- `iosApp/iosApp/ContentView.swift` — add `onOpenURL` handler +- `composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt` — parse command-line args + +### Files to Create +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/DeepLinkService.kt` — expect interface +- Platform actuals + +### Implementation +1. **Android**: Add intent filter to `MainActivity`: + ```xml + + + + + + + ``` + +2. **iOS**: Add `onOpenURL` in `ContentView.swift`: + ```swift + .onOpenURL { url in + // Pass to Compose via a callback + } + ``` + +3. **Desktop**: Parse `args` in `main.kt`: + ```kotlin + fun main(args: Array) { + val deepLink = args.firstOrNull { it.startsWith("spotube://") } + // Pass to Compose + } + ``` + +4. **DeepLinkService**: Parse URL, navigate to `Routes.Jam(sessionId)` + +--- + +## Implementation Order + +1. **Phase 0**: Rust uniffi WebRTC module (foundation for Jam) +2. **Phase 1**: Remote Control (simpler, LAN-only, no WebRTC needed) + - 1.1 Settings & Permissions + - 1.2 DNS-SD Discovery (using dns-sd-kt) + - 1.3 Extend LocalServer with WebSocket control routes + conditional bind + - 1.4 UI: Devices Screen + - 1.5 Connection Request Flow +3. **Phase 2**: Group Jam (WebRTC, manual SDP) + - 2.1 Jam Session Service + - 2.2 Jam Session UI + - 2.3 Play Interception +4. **Phase 3**: Deep-Link Handling (optional, can be deferred) + +--- + +## Key Files Summary + +### Rust +- `composeApp/Cargo.toml` — add `webrtc` dependency +- `composeApp/src/commonMain/rust/lib.rs` — register `webrtc_p2p` module +- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API + +### Settings +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` + +### Remote Control +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — **NEW**: wraps dns-sd-kt +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — **NEW**: handles WebSocket control connections +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — **NEW**: message definitions +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — **NEW**: remote player state proxy +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` — **NEW** +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` — **NEW** +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — **MODIFIED**: add WebSocket routes, conditional bind + +### Group Jam +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — **NEW** +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — **NEW** +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — **NEW** + +### Navigation & UI +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` +- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` + +### Permissions +- `composeApp/src/androidMain/AndroidManifest.xml` +- `iosApp/iosApp/Info.plist` + +--- + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| `webrtc-rs` `rtc` submodule not initialized | Document in setup: `cd build/webrtc-rs && git submodule update --init --recursive` | +| `ring` crypto cross-compilation for Android/iOS | Well-supported; may need NDK env vars for Android. Test early. | +| webrtc-rs is pre-release (0.21.0-beta.1) | API is stabilizing; pin version. Monitor for 1.0 release. | +| dns-sd-kt Apple targets use Swift interop (`spm4kmp`) | Published Maven Central artifacts include cinterop bindings. Should work transparently. | +| Manual SDP exchange is poor UX | Add QR code scanning as an alternative (Phase 2.2) | +| Queue sync conflicts in Jam | Host authority model: host's commands always win | +| WebRTC data channel reliability | Use ordered, reliable data channels (default in webrtc-rs) | +| Uniffi async/sync bridge for webrtc-rs | Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks | + +--- + +## Testing Strategy + +1. **Unit tests**: Test protocol serialization, queue sync logic +2. **Integration tests**: Test WebSocket server/client, DNS-SD discovery +3. **Manual tests**: + - Remote Control: Two devices on same LAN, control playback from one to another + - Group Jam: Three devices (1 host + 2 guests), sync queue and playback +4. **Cross-platform tests**: Verify on Android, iOS, JVM desktop (Linux/Windows/macOS) diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 5367c7ae..428f5064 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -8,6 +8,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -56,12 +77,133 @@ dependencies = [ "winnow", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive 0.6.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-toml" version = "0.1.10" @@ -71,12 +213,65 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -118,7 +313,29 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", ] [[package]] @@ -127,13 +344,98 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "compose-app" version = "0.1.0" dependencies = [ + "async-trait", + "bytes", "discord-rich-presence", "lofty", + "parking_lot", + "rtc", + "thiserror 2.0.18", + "tokio", "uniffi", + "webrtc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", ] [[package]] @@ -145,12 +447,83 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs 0.7.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "discord-rich-presence" version = "1.1.0" @@ -162,8 +535,19 @@ dependencies = [ "serde_derive", "serde_json", "serde_repr", - "thiserror", - "uuid", + "thiserror 2.0.18", + "uuid 0.8.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", ] [[package]] @@ -179,7 +563,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", ] [[package]] @@ -188,6 +592,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "flate2" version = "1.1.9" @@ -198,6 +608,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs-err" version = "2.11.0" @@ -207,6 +626,104 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -227,6 +744,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", ] [[package]] @@ -258,6 +776,125 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -268,12 +905,38 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -286,6 +949,21 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "lofty" version = "0.24.0" @@ -318,12 +996,31 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -340,6 +1037,50 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -350,6 +1091,40 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "ogg_pager" version = "0.7.2" @@ -359,30 +1134,108 @@ dependencies = [ "byteorder", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.2", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -392,6 +1245,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "quinn-udp" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" +dependencies = [ + "cfg_aliases", + "libc", + "log", + "socket2", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -407,25 +1293,434 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid 1.24.1", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "rtc" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "hex", + "log", + "pem", + "rand", + "rcgen", + "rtc-crypto", + "rtc-datachannel", + "rtc-dtls", + "rtc-ice", + "rtc-interceptor", + "rtc-mdns", + "rtc-media", + "rtc-rtcp", + "rtc-rtp", + "rtc-sctp", + "rtc-sdp", + "rtc-shared", + "rtc-srtp", + "rtc-stun", + "rtc-turn", + "rustls", + "sansio", + "serde", + "serde_json", + "unicase", + "url", + "x509-parser 0.16.0", +] + +[[package]] +name = "rtc-crypto" +version = "0.21.0-beta.1" +dependencies = [ + "aes", + "ccm", + "ctr", + "hmac", + "md-5", + "rand", + "ring", + "sha1", + "subtle", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "rtc-datachannel" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "log", + "rtc-sctp", + "rtc-shared", + "sansio", +] + +[[package]] +name = "rtc-dtls" +version = "0.21.0-beta.1" +dependencies = [ + "bytecheck", + "byteorder", + "bytes", + "der-parser 9.0.0", + "log", + "pem", + "rcgen", + "rkyv", + "rtc-crypto", + "rtc-shared", + "rustls", + "x509-parser 0.16.0", +] + +[[package]] +name = "rtc-ice" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "crc", + "log", + "rtc-crypto", + "rtc-mdns", + "rtc-shared", + "rtc-stun", + "sansio", + "serde", + "url", + "uuid 1.24.1", +] + +[[package]] +name = "rtc-interceptor" +version = "0.21.0-beta.1" +dependencies = [ + "log", + "rand", + "rtc-rtcp", + "rtc-rtp", + "rtc-shared", + "sansio", +] + +[[package]] +name = "rtc-mdns" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "log", + "rtc-shared", + "sansio", + "socket2", +] + +[[package]] +name = "rtc-media" +version = "0.21.0-beta.1" +dependencies = [ + "byteorder", + "bytes", + "rand", + "rtc-rtp", + "rtc-shared", + "thiserror 2.0.18", +] + +[[package]] +name = "rtc-rtcp" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "rtc-shared", +] + +[[package]] +name = "rtc-rtp" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "memchr", + "rand", + "rtc-shared", + "serde", +] + +[[package]] +name = "rtc-sctp" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "crc32c", + "log", + "rand", + "rtc-shared", + "rustc-hash", + "slab", + "thiserror 2.0.18", +] + +[[package]] +name = "rtc-sdp" +version = "0.21.0-beta.1" +dependencies = [ + "rand", + "rtc-shared", + "url", +] + +[[package]] +name = "rtc-shared" +version = "0.21.0-beta.1" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "nix", + "rand", + "serde", + "substring", + "thiserror 2.0.18", + "url", + "winapi", +] + +[[package]] +name = "rtc-srtp" +version = "0.21.0-beta.1" +dependencies = [ + "byteorder", + "bytes", + "rtc-crypto", + "rtc-rtcp", + "rtc-rtp", + "rtc-shared", +] + +[[package]] +name = "rtc-stun" +version = "0.21.0-beta.1" +dependencies = [ + "base64", + "bytes", + "crc", + "lazy_static", + "rand", + "rtc-crypto", + "rtc-shared", + "sansio", + "url", +] + +[[package]] +name = "rtc-turn" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "log", + "rtc-crypto", + "rtc-shared", + "rtc-stun", + "sansio", +] + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "sansio" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -510,30 +1805,96 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "smawk" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -556,6 +1917,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -566,7 +1938,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -578,13 +1950,33 @@ dependencies = [ "smawk", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -598,6 +1990,86 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "toml" version = "0.5.11" @@ -607,6 +2079,18 @@ dependencies = [ "serde", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -660,6 +2144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" dependencies = [ "anyhow", + "async-compat", "bytes", "once_cell", "static_assertions", @@ -732,6 +2217,30 @@ dependencies = [ "weedle2", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "0.8.2" @@ -741,12 +2250,90 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webrtc" +version = "0.21.0-beta.1" +dependencies = [ + "async-broadcast", + "async-channel", + "async-trait", + "bytes", + "event-listener", + "futures", + "log", + "quinn-udp", + "rtc", + "tokio", +] + [[package]] name = "weedle2" version = "5.0.0" @@ -756,12 +2343,43 @@ dependencies = [ "nom", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -771,6 +2389,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -780,6 +2462,140 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs 0.7.2", + "data-encoding", + "der-parser 10.0.0", + "lazy_static", + "nom", + "oid-registry 0.8.1", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 4357cc68..68dbc246 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -4,9 +4,16 @@ version = "0.1.0" edition = "2024" [dependencies] -uniffi = "0.29.4" +uniffi = { version = "0.29.4", features = ["tokio"] } lofty = "0.24.0" discord-rich-presence = "1.1.0" +thiserror = "2.0" +parking_lot = "0.12" +webrtc = { path = "../build/webrtc-rs" } +rtc = { path = "../build/webrtc-rs/rtc" } +async-trait = "0.1" +bytes = "1" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } [lib] crate-type = ["cdylib", "staticlib"] diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 34b20dd2..54122dfd 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -86,6 +86,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) + implementation(libs.dns.sd.kt) // Navigation implementation(libs.jetbrains.navigation3.ui) @@ -104,8 +105,10 @@ kotlin { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.websockets) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.websockets) // Zipline api(libs.zipline.core) implementation(libs.zipline.loader) @@ -160,6 +163,9 @@ kotlin { // Shimmer effect implementation(libs.compose.shimmer) implementation(libs.compose.placeholder.material3) + + // DLNA + implementation(libs.dns.sd.kt) } } commonTest.dependencies { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 1ee6b8ae..9e832592 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -16,13 +16,19 @@ ~ along with this program. If not, see . --> - + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 010d4259..56cd595c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -96,6 +96,13 @@ Enable Connect Expose remote playback controls through the Spotube Connect feature. Playback proxy server port + Allow remote control + Let other devices on the same network control playback when they connect. + Remote control device name + The name other devices see when discovering this one. Current: %1$s + Choose a friendly name to show to other devices on your network when they discover this Spotube instance. + e.g. Living Room Spotube + device hostname Port used by the playback proxy server. Current: %1$d Choose a port between 1 and 65535. 14769 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 665f351b..1a69b4e4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -23,8 +23,12 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository import dev.krtirtho.spotube.core.db.Database +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService +import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.navigation.navigationModule +import dev.krtirtho.spotube.core.remote.RemoteControlHandler +import dev.krtirtho.spotube.core.remote.RemoteControlService import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager @@ -38,6 +42,8 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel +import dev.krtirtho.spotube.modules.devices.DevicesViewModel +import dev.krtirtho.spotube.modules.jam.JamViewModel import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.home.HomeScreenRepository @@ -168,6 +174,8 @@ val sharedModules = module { // Blacklist singleOf(::BlacklistRepository) viewModelOf(::BlacklistViewModel) + viewModelOf(::DevicesViewModel) + viewModelOf(::JamViewModel) // Album singleOf(::AlbumRepository) @@ -205,6 +213,12 @@ val sharedModules = module { singleOf(::LocalServer) withOptions { createdAtStart() } + single { RemoteControlHandler(get(), get(), get()) } + singleOf(::DeviceDiscoveryService) + single { RemoteControlService(get(), get(), get()) } withOptions { + createdAtStart() + } + single { JamSessionService(get(), get()) } singleOf(::AudioPlayerQueueRepository) { bind() } single { DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt new file mode 100644 index 00000000..7c5d878b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -0,0 +1,100 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.discovery + +import com.appstractive.dnssd.DiscoveryEvent +import com.appstractive.dnssd.NetService +import com.appstractive.dnssd.createNetService +import com.appstractive.dnssd.discoverServices +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +data class DiscoveredDevice( + val name: String, + val type: String, + val host: String, + val port: Int, + val deviceId: String, +) { + val key: String get() = "$name$type".replace(".", "") +} + +sealed interface DiscoveryState { + data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState + data class Resolved(val device: DiscoveredDevice) : DiscoveryState + data class Removed(val device: DiscoveredDevice) : DiscoveryState +} + +class DeviceDiscoveryService { + companion object { + const val SERVICE_TYPE = "_spotube-ctrl._tcp" + const val TXT_DEVICE_ID = "deviceId" + } + + fun discover(): Flow = discoverServices(SERVICE_TYPE).map { event -> + when (event) { + is DiscoveryEvent.Discovered -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + ) + DiscoveryState.Discovered(device = device, resolve = event.resolve) + } + + is DiscoveryEvent.Resolved -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + ) + DiscoveryState.Resolved(device = device) + } + + is DiscoveryEvent.Removed -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = "", + ) + DiscoveryState.Removed(device = device) + } + } + } + + suspend fun advertise( + name: String, + port: Int, + deviceId: String, + ): NetService { + val service = createNetService( + type = SERVICE_TYPE, + name = name, + port = port, + txt = mapOf(TXT_DEVICE_ID to deviceId), + ) + service.register() + return service + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt new file mode 100644 index 00000000..ee8808c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -0,0 +1,178 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.jam + +import dev.krtirtho.spotube.core.audioplayer.LoopState +import dev.krtirtho.spotube.core.audioplayer.MediaItem +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class JamMessage { + @Serializable + @SerialName("hello") + data class Hello( + val displayName: String, + val deviceId: String, + ) : JamMessage() + + @Serializable + @SerialName("welcome") + data class Welcome( + val hostName: String, + val participantId: String, + ) : JamMessage() + + @Serializable + @SerialName("queueState") + data class QueueState( + val items: List, + val currentIndex: Int, + val isPlaying: Boolean, + val positionMs: Long, + ) : JamMessage() + + @Serializable + @SerialName("playbackCommand") + data class PlaybackCommand( + val command: PlaybackCmd, + ) : JamMessage() + + @Serializable + @SerialName("suggestTrack") + data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage() + + @Serializable + @SerialName("suggestPlaylist") + data class SuggestPlaylist(val tracks: List) : JamMessage() + + @Serializable + @SerialName("chat") + data class Chat( + val fromName: String, + val text: String, + ) : JamMessage() + + @Serializable + @SerialName("participantList") + data class ParticipantList(val participants: List) : JamMessage() + + @Serializable + @SerialName("leave") + data class Leave(val reason: String = "user_left") : JamMessage() +} + +@Serializable +sealed class PlaybackCmd { + @Serializable + @SerialName("play") + data object Play : PlaybackCmd() + + @Serializable + @SerialName("pause") + data object Pause : PlaybackCmd() + + @Serializable + @SerialName("toggle") + data object Toggle : PlaybackCmd() + + @Serializable + @SerialName("seek") + data class Seek(val positionMs: Long) : PlaybackCmd() + + @Serializable + @SerialName("skipNext") + data object SkipNext : PlaybackCmd() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : PlaybackCmd() + + @Serializable + @SerialName("setVolume") + data class SetVolume(val volume: Float) : PlaybackCmd() + + @Serializable + @SerialName("setLoop") + data class SetLoop(val loop: String) : PlaybackCmd() + + @Serializable + @SerialName("setShuffle") + data class SetShuffle(val enabled: Boolean) : PlaybackCmd() + + @Serializable + @SerialName("jumpTo") + data class JumpTo(val index: Int) : PlaybackCmd() +} + +@Serializable +data class JamMediaItem( + val url: String, + val title: String, + val artist: String, + val album: String, + val durationMs: Long, + val coverUrl: String, + val protocol: String, +) { + companion object { + fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( + url = item.url, + title = item.title, + artist = item.artist, + album = item.album, + durationMs = item.duration.inWholeMilliseconds, + coverUrl = item.coverURL, + protocol = item.protocol.name, + ) + + fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( + title = item.title, + artist = item.artist, + album = item.album, + duration = kotlin.time.Duration.parse("${item.durationMs}ms"), + coverURL = item.coverUrl, + url = item.url, + protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol + .valueOf(item.protocol), + ) + } +} + +@Serializable +data class JamParticipant( + val id: String, + val displayName: String, + val isHost: Boolean, +) + +@Serializable +enum class JamRole { + Host, + Guest, +} + +object JamLoopMapping { + fun toString(state: LoopState): String = state.name.lowercase() + fun fromString(value: String): LoopState = when (value.lowercase()) { + "none" -> LoopState.NONE + "one" -> LoopState.ONE + "all" -> LoopState.ALL + else -> LoopState.NONE + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt new file mode 100644 index 00000000..f90a91a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -0,0 +1,316 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.jam + +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent +import uniffi.compose_app.IceServerConfig +import uniffi.compose_app.WebrtcEventHandler +import uniffi.compose_app.WebrtcPeerConnection +import uniffi.compose_app.createWebrtcPeerConnection + +class JamSessionService( + private val audioPlayer: AudioPlayerInterface, + private val settingsProvider: SettingsProvider, +) : KoinComponent { + val logger by injectLogger() + private val log = Logger.withTag("JamSessionService") + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val _role = MutableStateFlow(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _isActive = MutableStateFlow(false) + val isActive: StateFlow = _isActive.asStateFlow() + + private val _localParticipantId = MutableStateFlow(null) + val localParticipantId: StateFlow = _localParticipantId.asStateFlow() + + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) + val incomingMessages = _incomingMessages.asSharedFlow() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _incomingSuggestions = MutableSharedFlow(extraBufferCapacity = 32) + val incomingSuggestions = _incomingSuggestions.asSharedFlow() + + private var hostConnection: WebrtcPeerConnection? = null + private val guestConnections = mutableMapOf() + private val guestLabels = mutableMapOf() + + private val eventHandler = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) { + // No-op in non-trickle mode: candidates are bundled into SDP + } + + override fun onIceGatheringStateChange(state: String) { + log.d { "ICE gathering state: $state" } + } + + override fun onConnectionStateChange(state: String) { + log.i { "Connection state: $state" } + } + + override fun onDataChannelOpen(label: String) { + log.i { "Data channel '$label' open" } + } + + override fun onDataChannelMessage(label: String, data: String) { + handleIncomingMessage(label, data) + } + + override fun onDataChannelClose(label: String) { + log.i { "Data channel '$label' closed" } + } + } + + suspend fun createSession(): String { + log.i { "Creating jam session" } + val settings = settingsProvider.settingsState.first() + val participantName = settings?.jamParticipantName?.ifBlank { + "Host-${randomShortId()}" + } ?: "Host" + + val pc = createWebrtcPeerConnection( + iceServers = listOf( + IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + ), + handler = eventHandler, + ) + + hostConnection = pc + _role.value = JamRole.Host + _localParticipantId.value = "host" + _participants.value = listOf( + JamParticipant( + id = "host", + displayName = participantName, + isHost = true, + ) + ) + _isActive.value = true + + pc.createDataChannel("jam") + val offer = pc.createOffer() + log.i { "Generated SDP offer (length=${offer.length})" } + return offer + } + + suspend fun acceptGuestAnswer(guestId: String, answer: String) { + val pc = guestConnections[guestId] ?: run { + log.w { "acceptGuestAnswer: no connection for $guestId" } + return + } + pc.setRemoteAnswer(answer) + } + + suspend fun joinSession(offer: String): String { + log.i { "Joining jam session" } + val settings = settingsProvider.settingsState.first() + val participantName = settings?.jamParticipantName?.ifBlank { + "Guest-${randomShortId()}" + } ?: "Guest" + + val pc = createWebrtcPeerConnection( + iceServers = listOf( + IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + ), + handler = eventHandler, + ) + + hostConnection = pc + _role.value = JamRole.Guest + _localParticipantId.value = "guest" + _isActive.value = true + + pc.setRemoteOffer(offer) + pc.createDataChannel("jam") + val answer = pc.createAnswer() + log.i { "Generated SDP answer (length=${answer.length})" } + return answer + } + + suspend fun hostAdmitGuest(guestOffer: String): String { + if (_role.value != JamRole.Host) { + error("hostAdmitGuest can only be called by the host") + } + val guestId = "guest-${guestConnections.size + 1}" + log.i { "Admitting guest $guestId" } + + val handler = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) {} + override fun onIceGatheringStateChange(state: String) {} + override fun onConnectionStateChange(state: String) {} + override fun onDataChannelOpen(label: String) {} + override fun onDataChannelMessage(label: String, data: String) { + handleIncomingMessage(label, data, guestId) + } + override fun onDataChannelClose(label: String) {} + } + + val pc = createWebrtcPeerConnection( + iceServers = listOf( + IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + ), + handler = handler, + ) + guestConnections[guestId] = pc + guestLabels[guestId] = "jam-$guestId" + + pc.setRemoteOffer(guestOffer) + pc.createDataChannel("jam-${guestId}") + val answer = pc.createAnswer() + return answer + } + + suspend fun sendMessage(message: JamMessage, guestId: String? = null) { + val json = json.encodeToString(JamMessage.serializer(), message) + when (_role.value) { + JamRole.Host -> { + if (guestId != null) { + guestConnections[guestId]?.sendData("jam-$guestId", json) + } else { + guestConnections.forEach { (id, pc) -> + pc.sendData("jam-$id", json) + } + } + } + + JamRole.Guest -> { + hostConnection?.sendData("jam", json) + } + + null -> log.w { "sendMessage called while no session is active" } + } + } + + suspend fun leave() { + log.i { "Leaving jam session" } + runCatching { sendMessage(JamMessage.Leave()) } + hostConnection?.shutdown() + guestConnections.values.forEach { runCatching { it.shutdown() } } + hostConnection = null + guestConnections.clear() + guestLabels.clear() + _role.value = null + _participants.value = emptyList() + _isActive.value = false + _localParticipantId.value = null + } + + private fun handleIncomingMessage(label: String, data: String, fromGuestId: String? = null) { + try { + val message = json.decodeFromString(JamMessage.serializer(), data) + _incomingMessages.tryEmit(message) + when (message) { + is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> { + _incomingSuggestions.tryEmit(message) + } + + is JamMessage.Leave -> { + if (_role.value == JamRole.Host && fromGuestId != null) { + val leavingPc = guestConnections.remove(fromGuestId) + guestLabels.remove(fromGuestId) + scope.launch { + runCatching { leavingPc?.shutdown() } + } + _participants.update { current -> + current.filterNot { it.id == fromGuestId } + } + } + } + + else -> Unit + } + } catch (e: Exception) { + log.w(e) { "Failed to parse jam message on $label" } + } + } + + suspend fun broadcastPlaybackCommand(command: PlaybackCmd) { + if (_role.value != JamRole.Host) return + sendMessage(JamMessage.PlaybackCommand(command)) + } + + suspend fun broadcastQueueState( + items: List, + currentIndex: Int, + isPlaying: Boolean, + positionMs: Long, + ) { + if (_role.value != JamRole.Host) return + sendMessage(JamMessage.QueueState(items, currentIndex, isPlaying, positionMs)) + } + + suspend fun suggestTrack(mediaItem: JamMediaItem) { + if (_role.value != JamRole.Guest) return + sendMessage(JamMessage.SuggestTrack(mediaItem)) + } + + suspend fun suggestPlaylist(tracks: List) { + if (_role.value != JamRole.Guest) return + sendMessage(JamMessage.SuggestPlaylist(tracks)) + } +} + +private fun MutableStateFlow.update(transform: (T) -> T) { + value = transform(value) +} + +private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt new file mode 100644 index 00000000..550de713 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt @@ -0,0 +1,162 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.jam + +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.coroutines.flow.first + +/** + * Manages queue synchronization between the host and the jam session. + * + * On the host: observes local playback state and broadcasts queue updates to guests. + * On the guest: receives queue updates and applies them to local playback. + * + * Conflict resolution: the host has authority. When a guest receives a queue state, + * it replaces the local queue. (and (The guest's local queue is essentially read-only + * during a jam session.) + */ +class QueueSyncManager( + private val audioPlayer: AudioPlayerInterface, + private val jamSession: JamSessionService, + private val scope: CoroutineScope, +) { + private val log = Logger.withTag("QueueSyncManager") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing.asStateFlow() + + private var hostBroadcastJob: Job? = null + private var guestApplyJob: Job? = null + private var guestCommandJob: Job? = null + + fun start() { + if (_isSyncing.value) return + _isSyncing.value = true + + when (jamSession.role.value) { + JamRole.Host -> startHostSync() + JamRole.Guest -> startGuestSync() + null -> { + _isSyncing.value = false + return + } + } + } + + fun stop() { + _isSyncing.value = false + hostBroadcastJob?.cancel() + guestApplyJob?.cancel() + guestCommandJob?.cancel() + hostBroadcastJob = null + guestApplyJob = null + guestCommandJob = null + } + + private fun startHostSync() { + hostBroadcastJob = scope.launch { + jamSession.role.first { it != null } + if (jamSession.role.value != JamRole.Host) return@launch + + jamSession.broadcastQueueState( + items = audioPlayer.playlistFlow.value.map(JamMediaItem::fromMediaItem), + currentIndex = audioPlayer.playlistFlow.value.indexOf( + audioPlayer.currentMediaItemFlow.value + ).coerceAtLeast(0), + isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + ) + + audioPlayer.playlistFlow.collect { playlist -> + audioPlayer.playerStateFlow.value.let { state -> + audioPlayer.positionFlow.value.let { position -> + jamSession.broadcastQueueState( + items = playlist.map(JamMediaItem::fromMediaItem), + currentIndex = playlist.indexOf(audioPlayer.currentMediaItemFlow.value) + .coerceAtLeast(0), + isPlaying = state == PlayerState.PLAYING, + positionMs = position.inWholeMilliseconds, + ) + } + } + } + } + } + + private fun startGuestSync() { + guestApplyJob = scope.launch { + jamSession.incomingMessages.collect { message -> + if (message !is JamMessage.QueueState) return@collect + applyQueueState(message) + } + } + + guestCommandJob = scope.launch { + jamSession.incomingMessages.collect { message -> + if (message !is JamMessage.PlaybackCommand) return@collect + applyPlaybackCommand(message.command) + } + } + } + + private suspend fun applyQueueState(state: JamMessage.QueueState) { + log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" } + val mediaItems = state.items.map(JamMediaItem::toMediaItem) + audioPlayer.load( + playlist = mediaItems, + autoPlay = state.isPlaying, + startPosition = state.currentIndex.coerceAtLeast(0), + ) + } + + private suspend fun applyPlaybackCommand(command: PlaybackCmd) { + log.d { "Applying playback command: $command" } + when (command) { + PlaybackCmd.Play -> audioPlayer.play() + PlaybackCmd.Pause -> audioPlayer.pause() + PlaybackCmd.Toggle -> { + if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + PlaybackCmd.SkipNext -> audioPlayer.skipToNext() + PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() + is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) + is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) + is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) + is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index d048c1d8..79f26337 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -21,6 +21,8 @@ import androidx.navigation3.runtime.NavKey import dev.krtirtho.spotube.modules.album.AlbumScreen import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen +import dev.krtirtho.spotube.modules.devices.DevicesScreen +import dev.krtirtho.spotube.modules.jam.JamScreen import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen @@ -75,6 +77,12 @@ sealed interface Routes : NavKey { @Serializable data object Blacklist : Routes + + @Serializable + data object Devices : Routes + + @Serializable + data object Jam : Routes } @OptIn(KoinExperimentalAPI::class) @@ -148,5 +156,11 @@ val navigationModule = module { navigation { BlacklistScreen() } + navigation { + DevicesScreen(navigationCommands = get()) + } + navigation { + JamScreen(navigationCommands = get()) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt index 7ef24dec..3f0ced21 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt @@ -61,6 +61,8 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Artist::class, Routes.Artist.serializer()) subclass(Routes.Album::class, Routes.Album.serializer()) subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) + subclass(Routes.Devices::class, Routes.Devices.serializer()) + subclass(Routes.Jam::class, Routes.Jam.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt new file mode 100644 index 00000000..cdda53c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt @@ -0,0 +1,91 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.remote + +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.style.TextAlign +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.koin.compose.koinInject + +@Composable +fun ConnectionRequestDialogHost() { + val handler: RemoteControlHandler = koinInject() + var pendingRequest by remember { mutableStateOf(null) } + + LaunchedEffect(handler) { + handler.incomingConnectionRequests.collect { request -> + pendingRequest = request + } + } + + pendingRequest?.let { request -> + AdaptiveDialogBottomSheet( + onDismiss = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + title = { + Text( + text = "Remote Control Request", + style = MaterialTheme.typography.titleLarge, + ) + }, + content = { + Text( + text = "\"${request.deviceName}\" wants to control playback on this device.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + }, + actions = { + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + ) { + Text("Deny") + } + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) + pendingRequest = null + }, + ) { + Text("Allow") + } + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) + pendingRequest = null + }, + ) { + Text("Allow Always") + } + }, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt new file mode 100644 index 00000000..15f24636 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -0,0 +1,262 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.remote + +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.LoopState +import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import io.ktor.server.websocket.WebSocketServerSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlin.coroutines.resume +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent + +class RemoteControlHandler( + private val settingsRepository: SettingsRepository, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, +) : KoinComponent { + val logger by injectLogger() + + val incomingConnectionRequests = MutableSharedFlow( + extraBufferCapacity = 16, + ) + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val pendingRequestResolutions = mutableMapOf Unit>() + + suspend fun handleConnection(session: WebSocketServerSession) { + val settings = settingsRepository.userSettings.first() + if (!settings.allowRemoteControl) { + logger.w { "Rejecting remote control connection: remote control is disabled" } + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Remote control is disabled")) + return + } + + val deviceId = session.call.request.headers["X-Device-Id"] + val deviceName = session.call.request.headers["X-Device-Name"] ?: "Unknown" + + val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices + + if (!isAllowed) { + val request = ConnectionRequest( + deviceId = deviceId ?: "unknown", + deviceName = deviceName, + sessionId = session.call.request.headers["X-Request-Id"] + ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}", + ) + incomingConnectionRequests.emit(request) + val response = waitForRequestResolution(request.sessionId) + if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Connection denied")) + return + } + if (response == ConnectionRequestResponse.AllowAlways && deviceId != null) { + settingsRepository.updateSettings( + settings.copy( + allowedRemoteDevices = (settings.allowedRemoteDevices + deviceId).distinct() + ) + ) + logger.i { "Device $deviceId added to always-allowed devices" } + } + } + + logger.i { "Remote control connection established from $deviceName ($deviceId)" } + + try { + handleControlLoop(session) + } catch (e: Exception) { + logger.w(e) { "Error in remote control session" } + } finally { + session.close() + } + } + + fun resolveConnectionRequest(sessionId: String, response: ConnectionRequestResponse) { + pendingRequestResolutions.remove(sessionId)?.invoke(response) + } + + private suspend fun waitForRequestResolution(sessionId: String): ConnectionRequestResponse { + return suspendCancellableCoroutine { continuation -> + pendingRequestResolutions[sessionId] = { response -> + if (continuation.isActive) { + continuation.resume(response) + } + } + continuation.invokeOnCancellation { + pendingRequestResolutions.remove(sessionId) + } + } + } + + private suspend fun handleControlLoop(session: WebSocketServerSession) { + for (frame in session.incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + try { + val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) + handleCommand(session, envelope) + } catch (e: Exception) { + logger.w(e) { "Failed to parse remote control command" } + sendError(session, "Invalid command: ${e.message}") + } + } + } + } + + private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { + when (val command = envelope.command) { + is RemoteControlCommand.Play -> { + logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" } + } + is RemoteControlCommand.Pause -> { + audioPlayer.pause() + } + is RemoteControlCommand.TogglePlayPause -> { + if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + is RemoteControlCommand.Seek -> { + audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + } + is RemoteControlCommand.SetVolume -> { + audioPlayer.setVolume(command.volume) + } + is RemoteControlCommand.SkipNext -> { + audioPlayer.skipToNext() + } + is RemoteControlCommand.SkipPrevious -> { + audioPlayer.skipToPrevious() + } + is RemoteControlCommand.SetShuffle -> { + audioPlayer.shuffle(command.enabled) + } + is RemoteControlCommand.SetLoopMode -> { + val loopState = when (command.mode) { + "none" -> LoopState.NONE + "one" -> LoopState.ONE + "all" -> LoopState.ALL + else -> { + sendError(session, "Invalid loop mode: ${command.mode}") + return + } + } + audioPlayer.loop(loopState) + } + is RemoteControlCommand.AddToQueue -> { + logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } + } + is RemoteControlCommand.RemoveFromQueue -> { + audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl) + } + } + sendAck(session, envelope.commandId) + broadcastState(session) + } + + private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { + val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId)) + session.send(Frame.Text(text)) + } + + private suspend fun sendError(session: WebSocketServerSession, message: String) { + val text = json.encodeToString(RemoteControlEvent.Error.serializer(), RemoteControlEvent.Error(message)) + session.send(Frame.Text(text)) + } + + private suspend fun broadcastState(session: WebSocketServerSession) { + val current = audioPlayerQueue.currentQueueEntryFlow.value + val state = RemoteControlEvent.PlayerState( + isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds, + volume = audioPlayer.volumeFlow.value, + shuffleEnabled = audioPlayer.shuffleModeFlow.value, + loopMode = audioPlayer.loopStateFlow.value.name.lowercase(), + currentTrackId = current?.mediaKey(), + currentTrackTitle = current?.titleOrNull(), + currentTrackArtists = current?.artistsOrNull(), + currentTrackAlbum = current?.albumOrNull(), + currentTrackCoverUrl = current?.coverUrlOrNull(), + ) + val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.mediaKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.titleOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.title + is QueueEntry.LocalTrack -> name + } + + private fun QueueEntry.artistsOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.artists.joinToString(", ") { artist -> artist.name } + is QueueEntry.LocalTrack -> artists.joinToString(", ") + } + + private fun QueueEntry.albumOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.album?.title + is QueueEntry.LocalTrack -> album + } + + private fun QueueEntry.coverUrlOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url + is QueueEntry.LocalTrack -> null + } +} + +@Serializable +data class CommandEnvelope( + val commandId: String, + val command: RemoteControlCommand, +) + +data class ConnectionRequest( + val deviceId: String, + val deviceName: String, + val sessionId: String, +) + +enum class ConnectionRequestResponse { + Allow, + AllowAlways, + Deny, +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt new file mode 100644 index 00000000..b9cacd5c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -0,0 +1,113 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class RemoteControlCommand { + @Serializable + @SerialName("play") + data class Play(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("pause") + data object Pause : RemoteControlCommand() + + @Serializable + @SerialName("togglePlayPause") + data object TogglePlayPause : RemoteControlCommand() + + @Serializable + @SerialName("seek") + data class Seek(val positionMs: Long) : RemoteControlCommand() + + @Serializable + @SerialName("setVolume") + data class SetVolume(val volume: Float) : RemoteControlCommand() + + @Serializable + @SerialName("skipNext") + data object SkipNext : RemoteControlCommand() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : RemoteControlCommand() + + @Serializable + @SerialName("setShuffle") + data class SetShuffle(val enabled: Boolean) : RemoteControlCommand() + + @Serializable + @SerialName("setLoopMode") + data class SetLoopMode(val mode: String) : RemoteControlCommand() + + @Serializable + @SerialName("addToQueue") + data class AddToQueue(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("removeFromQueue") + data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() +} + +@Serializable +sealed class RemoteControlEvent { + @Serializable + @SerialName("playerState") + data class PlayerState( + val isPlaying: Boolean, + val positionMs: Long, + val durationMs: Long, + val volume: Float, + val shuffleEnabled: Boolean, + val loopMode: String, + val currentTrackId: String?, + val currentTrackTitle: String?, + val currentTrackArtists: String?, + val currentTrackAlbum: String?, + val currentTrackCoverUrl: String?, + ) : RemoteControlEvent() + + @Serializable + @SerialName("queueUpdated") + data class QueueUpdated( + val entries: List, + val currentIndex: Int, + ) : RemoteControlEvent() + + @Serializable + @SerialName("ack") + data class Ack(val commandId: String) : RemoteControlEvent() + + @Serializable + @SerialName("error") + data class Error(val message: String) : RemoteControlEvent() +} + +@Serializable +data class RemoteQueueEntry( + val mediaUrl: String, + val trackId: String, + val title: String, + val artists: String, + val album: String?, + val coverUrl: String?, + val durationMs: Long, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt new file mode 100644 index 00000000..0e342da4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -0,0 +1,104 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.remote + +import co.touchlab.kermit.Logger +import com.appstractive.dnssd.NetService +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService +import dev.krtirtho.spotube.core.server.LocalServer +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * Advertises this device on the local network via DNS-SD so that other Spotube + * instances can discover and control it. Advertises only while the + * "Allow remote control" setting is enabled and the local playback server is + * listening on the LAN (0.0.0.0). + */ +class RemoteControlService( + private val settingsRepository: SettingsRepository, + private val discoveryService: DeviceDiscoveryService, + private val localServer: LocalServer, +) { + private val log = Logger.withTag("RemoteControlService") + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var advertisedService: NetService? = null + + init { + scope.launch { + combine( + settingsRepository.userSettings, + localServer.port, + ) { settings, port -> settings to port } + .distinctUntilChanged() + .collect { (settings, port) -> + if (settings.allowRemoteControl && port != null) { + ensureAdvertised(settings.remoteControlDeviceName, port) + } else { + stopAdvertising() + } + } + } + } + + private suspend fun ensureAdvertised(name: String, port: Int) { + val deviceId = resolveDeviceId() + val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } + if (advertisedService == null) { + try { + advertisedService = discoveryService.advertise( + name = serviceName, + port = port, + deviceId = deviceId, + ) + log.i { "Advertising remote control service '$serviceName' on port $port" } + } catch (e: Exception) { + log.w(e) { "Failed to advertise remote control service" } + } + } + } + + private suspend fun stopAdvertising() { + if (advertisedService != null) { + runCatching { advertisedService?.unregister() } + advertisedService = null + log.i { "Stopped advertising remote control service" } + } + } + + private suspend fun resolveDeviceId(): String { + val settings = settingsRepository.userSettings.first() + if (settings.remoteControlDeviceId.isNotBlank()) { + return settings.remoteControlDeviceId + } + val generated = buildString(16) { + val chars = "0123456789abcdef" + repeat(16) { append(chars[Random.nextInt(chars.length)]) } + } + settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) + return generated + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt index 6725f215..c61f45c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt @@ -19,10 +19,12 @@ package dev.krtirtho.spotube.core.server import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.modules.settings.SettingsViewModel import io.ktor.client.HttpClient import io.ktor.http.HttpMethod import io.ktor.server.application.Application +import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer @@ -30,6 +32,8 @@ import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.head import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -55,6 +59,7 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, + private val remoteControlHandler: RemoteControlHandler, ) : KoinComponent { val logger by injectLogger() @@ -70,8 +75,10 @@ class LocalServer( private val activePort = MutableStateFlow(null) val port = activePort.asStateFlow() + private val activeHost = MutableStateFlow(null) + val baseUrl = activePort.map { port -> - port?.let { "http://$HOST:$it" } + port?.let { "http://$HOST_LOCAL:$it" } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) private val cachedCacheEnabled = MutableStateFlow(false) @@ -89,18 +96,19 @@ class LocalServer( } companion object { - private const val HOST = "127.0.0.1" + private const val HOST_LOCAL = "127.0.0.1" + private const val HOST_LAN = "0.0.0.0" } init { logger.d { "Starting playback proxy port watcher" } portWatcher = scope.launch { settingsViewModel.settingsState - .mapNotNull { it?.playbackProxyServerPort } + .mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } } .distinctUntilChanged() - .collectLatest { port -> - logger.d { "Observed playback proxy port change to $port" } - restartServer(port) + .collectLatest { (port, allowRemoteControl) -> + logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" } + restartServer(port, allowRemoteControl) } } scope.launch { @@ -129,26 +137,28 @@ class LocalServer( logger.d { "Playback proxy server stopped" } } - private suspend fun restartServer(port: Int) { + private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) { + val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL serverMutex.withLock { - if (serverState.value != null && activePort.value == port) { - logger.v { "Playback proxy server already running on port $port; skipping restart" } + if (serverState.value != null && activePort.value == port && activeHost.value == host) { + logger.v { "Playback proxy server already running on $host:$port; skipping restart" } return } - logger.d { "Restarting playback proxy server on port $port" } + logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" } stopServerLocked() serverState.value = embeddedServer( factory = CIO, - host = HOST, + host = host, port = port, module = { configureRoutes() } ).also { engine -> engine.start(wait = false) } activePort.value = port - logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$HOST:$port"}" } + activeHost.value = host + logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:$port"}" } } } @@ -165,9 +175,16 @@ class LocalServer( } serverState.value = null activePort.value = null + activeHost.value = null } private fun Application.configureRoutes() { + install(WebSockets) { + pingPeriodMillis = 30_000L + timeoutMillis = 60_000L + maxFrameSize = 10L * 1024 * 1024 + masking = false + } routing { get("/health") { call.respondText("ok") @@ -188,6 +205,10 @@ class LocalServer( get("/segment/{trackId}") { streamProxy.handleSegmentRequest(call) } + + webSocket("/control") { + remoteControlHandler.handleConnection(this) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt new file mode 100644 index 00000000..d9b3bf28 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -0,0 +1,181 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun DevicesScreen( + navigationCommands: NavigationCommands, +) { + val viewModel = koinViewModel() + val devices by viewModel.devices.collectAsStateWithLifecycle() + val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() + + DisposableEffect(Unit) { + viewModel.startDiscovery() + onDispose { viewModel.stopDiscovery() } + } + + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Devices") }, + actions = { + if (isDiscovering) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = "Refresh", + modifier = Modifier + .size(24.dp) + .clickable { viewModel.startDiscovery() }, + ) + } + }, + ) + }, + ) { innerPadding -> + val shellBottomInset = LocalAppShellBottomInset.current + + if (devices.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (isDiscovering) { + "Searching for devices on the network..." + } else { + "No devices found" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!isDiscovering) { + Text( + text = "Make sure the other device has \"Allow remote control\" enabled in settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + items(devices.values.toList(), key = { it.key }) { device -> + DeviceRow( + device = device, + onClick = { viewModel.connectToDevice(device) }, + ) + } + item { + Box(modifier = Modifier.padding(bottom = shellBottomInset)) + } + } + } + } +} + +@Composable +private fun DeviceRow( + device: DiscoveredDevice, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${device.host}:${device.port}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt new file mode 100644 index 00000000..5c8809f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -0,0 +1,79 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.appstractive.dnssd.NetService +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService +import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.discovery.DiscoveryState +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class DevicesViewModel : ViewModel(), KoinComponent { + private val logger = Logger.withTag("DevicesViewModel") + private val discoveryService: DeviceDiscoveryService by inject() + + private val _devices = MutableStateFlow>(emptyMap()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _isDiscovering = MutableStateFlow(false) + val isDiscovering: StateFlow = _isDiscovering.asStateFlow() + + private var discoveryJob: Job? = null + private var advertisedService: NetService? = null + + fun startDiscovery() { + if (discoveryJob?.isActive == true) return + _isDiscovering.value = true + discoveryJob = viewModelScope.launch { + discoveryService.discover().collect { event -> + when (event) { + is DiscoveryState.Discovered -> { + event.resolve() + _devices.update { it + (event.device.key to event.device.copy()) } + } + is DiscoveryState.Resolved -> { + _devices.update { it + (event.device.key to event.device) } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } + } + } + } + } + + fun stopDiscovery() { + discoveryJob?.cancel() + discoveryJob = null + _isDiscovering.value = false + } + + fun connectToDevice(device: DiscoveredDevice) { + logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index c34d53ee..012e6947 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -66,8 +68,14 @@ import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.User +import org.koin.compose.koinInject import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -75,6 +83,7 @@ import kotlinx.coroutines.flow.map @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(viewModel: HomeScreenViewModel) { + val navigationCommands = koinInject() val platform = getPlatform() val isDesktop = platform.type == PlatformType.Windows || platform.type == PlatformType.Linux || @@ -117,7 +126,21 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { backButton = false, title = { Text("Browse") - } + }, + actions = { + IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = "Devices", + ) + } + IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) { + Icon( + imageVector = Iconsax.User, + contentDescription = "Group Jam", + ) + } + }, ) }, ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt new file mode 100644 index 00000000..93b3ca37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -0,0 +1,227 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.jam + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun JamScreen( + navigationCommands: NavigationCommands, +) { + val viewModel = koinViewModel() + val isActive by viewModel.isActive.collectAsStateWithLifecycle() + val pendingOffer by viewModel.pendingHostOffer.collectAsStateWithLifecycle() + val pendingAnswer by viewModel.pendingGuestAnswer.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + + LaunchedEffect(isActive) { + if (isActive && navigationCommands != null) { + // navigationCommands doesn't navigate here automatically; + // the session screen is the same screen so we just stay. + } + } + + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Group Jam") }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (error != null) { + Text( + text = error ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + if (pendingOffer == null && pendingAnswer == null) { + CreateOrJoinView( + onCreate = { viewModel.createSession() }, + onJoin = { offer -> viewModel.joinSession(offer) }, + ) + } else if (pendingOffer != null) { + HostOfferView( + offer = pendingOffer!!, + onLeave = { viewModel.leave() }, + ) + } else if (pendingAnswer != null) { + GuestAnswerView( + answer = pendingAnswer!!, + onLeave = { viewModel.leave() }, + ) + } + } + } +} + +@Composable +private fun CreateOrJoinView( + onCreate: () -> Unit, + onJoin: (String) -> Unit, +) { + var tab by remember { mutableIntStateOf(0) } + var offer by remember { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = "Listen Together with friends", + style = MaterialTheme.typography.titleLarge, + ) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = tab == 0, + onClick = { tab = 0 }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text("Create") } + SegmentedButton( + selected = tab == 1, + onClick = { tab = 1 }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text("Join") } + } + + if (tab == 0) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Create a new jam session. You'll be the host and can control playback. Share the SDP offer with your friends so they can join.", + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onCreate) { + Text("Create Session") + } + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Paste the SDP offer from the host below. You'll get an SDP answer to send back.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = offer, + onValueChange = { offer = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Host's SDP offer") }, + minLines = 3, + maxLines = 6, + ) + Button( + onClick = { onJoin(offer.trim()) }, + enabled = offer.isNotBlank(), + ) { + Text("Generate Answer") + } + } + } + } +} + +@Composable +private fun HostOfferView( + offer: String, + onLeave: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Session created. Send this SDP offer to your friends:", + style = MaterialTheme.typography.bodyMedium, + ) + SelectionContainer { + OutlinedTextField( + value = offer, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + label = { Text("SDP Offer (copy and send to guests)") }, + minLines = 4, + maxLines = 10, + ) + } + Text( + text = "When a guest responds with an SDP answer, use the JamSessionScreen to add them.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onLeave) { + Text("Leave Session") + } + } +} + +@Composable +private fun GuestAnswerView( + answer: String, + onLeave: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "You've joined the session. Send this SDP answer back to the host:", + style = MaterialTheme.typography.bodyMedium, + ) + SelectionContainer { + OutlinedTextField( + value = answer, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + label = { Text("SDP Answer (copy and send to host)") }, + minLines = 4, + maxLines = 10, + ) + } + Button(onClick = onLeave) { + Text("Leave Session") + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt new file mode 100644 index 00000000..10500045 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -0,0 +1,85 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.jam + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.jam.JamMessage +import dev.krtirtho.spotube.core.jam.JamParticipant +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamSessionService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class JamViewModel : ViewModel(), KoinComponent { + private val jamSession: JamSessionService by inject() + private val audioPlayer: AudioPlayerInterface by inject() + + val role: StateFlow = jamSession.role + val participants: StateFlow> = jamSession.participants + val isActive: StateFlow = jamSession.isActive + + private val _pendingHostOffer = MutableStateFlow(null) + val pendingHostOffer: StateFlow = _pendingHostOffer.asStateFlow() + + private val _pendingGuestAnswer = MutableStateFlow(null) + val pendingGuestAnswer: StateFlow = _pendingGuestAnswer.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + fun createSession() { + viewModelScope.launch { + try { + val offer = jamSession.createSession() + _pendingHostOffer.value = offer + } catch (e: Exception) { + _error.value = "Failed to create session: ${e.message}" + } + } + } + + fun joinSession(offer: String) { + viewModelScope.launch { + try { + val answer = jamSession.joinSession(offer) + _pendingGuestAnswer.value = answer + } catch (e: Exception) { + _error.value = "Failed to join session: ${e.message}" + } + } + } + + fun leave() { + viewModelScope.launch { + jamSession.leave() + _pendingHostOffer.value = null + _pendingGuestAnswer.value = null + } + } + + fun clearError() { + _error.value = null + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt new file mode 100644 index 00000000..9c1f30c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt @@ -0,0 +1,63 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.jam + +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import dev.krtirtho.spotube.core.ui.base.ThemedDialog + +/** + * Dialog shown when the user tries to play a collection while a jam session is active. + * The user picks between playing locally on their device or suggesting it to the jam session. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onSuggestToJam: () -> Unit, +) { + if (!visible) return + + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text("Play Where?", style = MaterialTheme.typography.titleLarge) + }, + content = { + Text( + text = "You have an active jam session. Choose where to play this collection.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + TextButton(onClick = onPlayLocally) { + Text("Play here") + } + Button(onClick = onSuggestToJam) { + Text("Suggest to Jam") + } + }, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index cfd8ed3e..1878ae96 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -55,6 +55,15 @@ data class UserSettings( val enableConnect: Boolean = false, val playbackProxyServerPort: Int = 14769, + // Remote Control (LAN) + val allowRemoteControl: Boolean = false, + val allowedRemoteDevices: List = emptyList(), + val remoteControlDeviceName: String = "", + val remoteControlDeviceId: String = "", + + // Group Jam (P2P) + val jamParticipantName: String = "", + // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder val localMediaFolders: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 4dee2d85..658e6119 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -38,6 +38,7 @@ import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay @@ -135,6 +136,46 @@ internal fun LazyListScope.playbackSection( } ) }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + } + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + }, { val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_port_range = stringResource(Res.string.settings_error_port_range) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index a3f7e1c3..e41b2ef5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -69,6 +69,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -116,6 +117,8 @@ fun AppShell( } } + ConnectionRequestDialogHost() + Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index b6e82e55..2cbdfe0d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -68,8 +68,10 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken +import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.tabs import org.jetbrains.compose.resources.Font import org.koin.compose.koinInject @@ -103,6 +105,7 @@ fun AppSidebar( }, horizontalAlignment = Alignment.CenterHorizontally ) { + Column(modifier = Modifier.weight(1f)) { Row( modifier = Modifier .fillMaxWidth() @@ -175,6 +178,22 @@ fun AppSidebar( ) } } + } + + SidebarItem( + label = "Devices", + activeIcon = Iconsax.IconsaxMirroringScreen, + onClick = { navigator.navigate(Routes.Devices) }, + selected = false, + expanded = expanded, + ) + SidebarItem( + label = "Group Jam", + activeIcon = Iconsax.User, + onClick = { navigator.navigate(Routes.Jam) }, + selected = false, + expanded = expanded, + ) } } diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index b5ab652c..dff859ba 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -1,7 +1,9 @@ mod metadata; mod discord_rpc; +mod webrtc_p2p; pub use metadata::*; pub use discord_rpc::*; +pub use webrtc_p2p::*; -uniffi::setup_scaffolding!(); +uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs new file mode 100644 index 00000000..ba3ed9d9 --- /dev/null +++ b/composeApp/src/commonMain/rust/webrtc_p2p.rs @@ -0,0 +1,289 @@ +/* + * 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 . + */ + +use std::sync::Arc; + +use parking_lot::Mutex; +use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors; +use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit}; +use webrtc::peer_connection::{ + MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, + RTCConfigurationBuilder, RTCIceGatheringState, RTCIceServer, RTCPeerConnectionIceEvent, + RTCPeerConnectionState, RTCSessionDescription, Registry, +}; +use webrtc::runtime::channel; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum WebrtcError { + #[error("SDP error: {reason}")] + SdpError { reason: String }, + #[error("Connection error: {reason}")] + ConnectionError { reason: String }, + #[error("Data channel error: {reason}")] + DataChannelError { reason: String }, + #[error("Invalid state: {reason}")] + InvalidState { reason: String }, + #[error("Internal error: {reason}")] + Internal { reason: String }, +} + +impl From for WebrtcError { + fn from(e: webrtc::error::Error) -> Self { + WebrtcError::Internal { + reason: format!("{e:?}"), + } + } +} + +#[derive(uniffi::Record)] +pub struct IceServerConfig { + pub urls: Vec, + pub username: String, + pub credential: String, +} + +#[uniffi::export(callback_interface)] +pub trait WebrtcEventHandler: Send + Sync + 'static { + fn on_ice_candidate(&self, candidate: String); + fn on_ice_gathering_state_change(&self, state: String); + fn on_connection_state_change(&self, state: String); + fn on_data_channel_open(&self, label: String); + fn on_data_channel_message(&self, label: String, data: String); + fn on_data_channel_close(&self, label: String); +} + +struct DataChannelEntry { + dc: Arc, + label: String, +} + +#[derive(uniffi::Object)] +pub struct WebrtcPeerConnection { + pc: Arc, + handler: Arc, + channels: Mutex>, + gather_rx: Mutex>, +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn create_webrtc_peer_connection( + ice_servers: Vec, + handler: Box, +) -> Result, WebrtcError> { + let handler: Arc = Arc::from(handler); + + let mut media_engine = MediaEngine::default(); + media_engine + .register_default_codecs() + .map_err(|e| WebrtcError::Internal { + reason: format!("media_engine: {e:?}"), + })?; + + let registry = register_default_interceptors(Registry::new(), &mut media_engine) + .map_err(|e| WebrtcError::Internal { + reason: format!("interceptor_registry: {e:?}"), + })?; + + let config = RTCConfigurationBuilder::new() + .with_ice_servers( + ice_servers + .into_iter() + .map(|s| RTCIceServer { + urls: s.urls, + username: s.username, + credential: s.credential, + }) + .collect(), + ) + .build(); + + let (gather_tx, gather_rx) = channel::<()>(1); + let pc_handler = Arc::new(PeerHandlerBridge { + handler: Arc::clone(&handler), + gather_tx, + }); + + let pc = PeerConnectionBuilder::new() + .with_configuration(config) + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .with_handler(pc_handler) + .with_udp_addrs(vec!["0.0.0.0:0"]) + .build() + .await?; + + Ok(Arc::new(WebrtcPeerConnection { + pc: Arc::new(pc) as Arc, + handler, + channels: Mutex::new(Vec::new()), + gather_rx: Mutex::new(gather_rx), + })) +} + +impl WebrtcPeerConnection { + /// Waits for ICE gathering to reach `Complete` so the local SDP includes all + /// candidates (non-trickle exchange). Must be called after `set_local_description`, + /// which is what starts gathering. + async fn wait_for_ice_gathering(&self) { + let mut gather_rx = self.gather_rx.lock().clone(); + let _ = gather_rx.recv().await; + } +} + +#[uniffi::export] +impl WebrtcPeerConnection { + #[uniffi::method(async_runtime = "tokio")] + pub async fn create_offer(&self) -> Result { + let offer = self.pc.create_offer(None).await?; + self.pc.set_local_description(offer.clone()).await?; + self.wait_for_ice_gathering().await; + Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(offer.sdp)) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn create_answer(&self) -> Result { + let answer = self.pc.create_answer(None).await?; + self.pc.set_local_description(answer.clone()).await?; + self.wait_for_ice_gathering().await; + Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(answer.sdp)) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn set_remote_offer(&self, sdp: String) -> Result<(), WebrtcError> { + let desc = RTCSessionDescription::offer(sdp) + .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; + self.pc.set_remote_description(desc).await?; + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn set_remote_answer(&self, sdp: String) -> Result<(), WebrtcError> { + let desc = RTCSessionDescription::answer(sdp) + .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; + self.pc.set_remote_description(desc).await?; + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn local_description(&self) -> Option { + self.pc.local_description().await.map(|d| d.sdp) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn create_data_channel(&self, label: String) -> Result<(), WebrtcError> { + let dc = self + .pc + .create_data_channel(&label, None::) + .await?; + + spawn_data_channel_poll_loop(Arc::clone(&dc), Arc::clone(&self.handler)); + + self.channels.lock().push(DataChannelEntry { dc, label }); + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn send_data(&self, label: String, data: String) -> Result<(), WebrtcError> { + let dc = { + let channels = self.channels.lock(); + channels + .iter() + .find(|c| c.label == label) + .map(|c| Arc::clone(&c.dc)) + }; + let dc = dc.ok_or_else(|| WebrtcError::InvalidState { + reason: format!("No data channel with label '{label}'"), + })?; + dc.send_text(&data).await?; + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn shutdown(&self) -> Result<(), WebrtcError> { + let channels: Vec> = { + let channels = self.channels.lock(); + channels.iter().map(|c| Arc::clone(&c.dc)).collect() + }; + for dc in channels.iter() { + let _ = dc.close().await; + } + self.pc.close().await?; + Ok(()) + } +} + +struct PeerHandlerBridge { + handler: Arc, + gather_tx: webrtc::runtime::Sender<()>, +} + +#[async_trait::async_trait] +impl PeerConnectionEventHandler for PeerHandlerBridge { + async fn on_ice_candidate(&self, event: RTCPeerConnectionIceEvent) { + self.handler.on_ice_candidate(event.candidate.to_string()); + } + + async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) { + let s = state.to_string(); + if matches!(state, RTCIceGatheringState::Complete) { + let _ = self.gather_tx.try_send(()); + } + self.handler.on_ice_gathering_state_change(s); + } + + async fn on_connection_state_change(&self, state: RTCPeerConnectionState) { + self.handler.on_connection_state_change(state.to_string()); + } + + async fn on_data_channel(&self, dc: Arc) { + spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler)); + } +} + +fn spawn_data_channel_poll_loop( + dc: Arc, + handler: Arc, +) { + ::tokio::spawn(async move { + let label = match dc.label().await { + Ok(l) => l, + Err(_) => return, + }; + while let Some(event) = dc.poll().await { + match event { + DataChannelEvent::OnOpen => { + handler.on_data_channel_open(label.clone()); + } + DataChannelEvent::OnMessage(msg) => { + let text = if msg.is_string { + String::from_utf8_lossy(&msg.data).into_owned() + } else { + format!("[binary:{}bytes]", msg.data.len()) + }; + handler.on_data_channel_message(label.clone(), text); + } + DataChannelEvent::OnClose | DataChannelEvent::OnClosing => { + handler.on_data_channel_close(label.clone()); + if matches!(event, DataChannelEvent::OnClose) { + break; + } + } + _ => {} + } + } + }); +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04084c5a..4c59d3c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ vlcj = "4.12.1" vlcjNative = "4.12.0" ziplineVersion = "1.27.0" ktor = "3.5.1" +dnssdkt = "1.1.0" kotlinStdlib = "2.4.10" runner = "1.7.0" core = "1.7.0" @@ -85,6 +86,7 @@ androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" } @@ -117,6 +119,8 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1d..870b2c71 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,12 @@ CADisableMinimumFrameDurationOnPhone + NSLocalNetworkUsageDescription + Required to discover local network devices + NSBonjourServices + + _http._tcp + _spotube-ctrl._tcp + From f21442b1c27b5cad96e5206f02a18dd401125280 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 28 Aug 2026 09:33:39 +0600 Subject: [PATCH 003/132] feat(deeplinks): implement deep link handling for jam sessions --- .../src/androidMain/AndroidManifest.xml | 11 + .../dev/krtirtho/spotube/MainActivity.kt | 8 + .../kotlin/dev/krtirtho/spotube/App.kt | 9 + .../core/deeplink/ExternalUriHandler.kt | 50 +++ .../core/deeplink/JamDeepLinkService.kt | 50 +++ .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../core/discovery/DeviceDiscoveryService.kt | 5 +- .../spotube/core/jam/JamInviteCodec.kt | 105 +++++ .../spotube/core/jam/JamSessionService.kt | 360 ++++++++++-------- .../krtirtho/spotube/modules/jam/JamScreen.kt | 315 +++++++++++---- .../spotube/modules/jam/JamViewModel.kt | 202 ++++++++-- .../kotlin/dev/krtirtho/spotube/main.kt | 22 +- iosApp/iosApp/Info.plist | 11 + iosApp/iosApp/iOSApp.swift | 3 + 14 files changed, 890 insertions(+), 263 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 9e832592..3245b681 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -55,6 +55,17 @@ + + + + + + + + + () val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) + val jamDeepLinks: JamDeepLinkService = koinInject() + DisposableEffect(Unit) { + ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) } + onDispose { ExternalUriHandler.listener = null } + } + val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt new file mode 100644 index 00000000..00deb04d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt @@ -0,0 +1,50 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.deeplink + +/** + * Cross-platform receiver for URIs handed to the app by the operating system + * (deep links). Platform entry points (Android activity intents, desktop command + * line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable + * installs a [listener] once composition starts. + * + * Follows the Compose Multiplatform deep linking docs pattern: URIs arriving + * before a listener is installed are cached and delivered as soon as one is set. + */ +object ExternalUriHandler { + private var cached: String? = null + + var listener: ((uri: String) -> Unit)? = null + set(value) { + field = value + if (value != null) { + cached?.let(value::invoke) + cached = null + } + } + + fun onNewUri(uri: String) { + if (uri.isBlank()) return + val currentListener = listener + if (currentListener != null) { + currentListener(uri) + } else { + cached = uri + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt new file mode 100644 index 00000000..32e529db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt @@ -0,0 +1,50 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.deeplink + +import dev.krtirtho.spotube.core.jam.JamInviteCodec +import dev.krtirtho.spotube.core.jam.JamInviteLink +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI, + * and navigates to [Routes.Jam] so the user lands where the link is handled. + */ +class JamDeepLinkService( + private val navigationCommands: NavigationCommands, +) { + private val _pendingLink = MutableStateFlow(null) + val pendingLink: StateFlow = _pendingLink.asStateFlow() + + fun handleUri(uri: String) { + val link = JamInviteCodec.parse(uri) ?: return + _pendingLink.value = link + navigationCommands.navigateTo(Routes.Jam) + } + + /** Consumes the currently pending link (if any). */ + fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null } + + fun clear() { + _pendingLink.value = null + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 1a69b4e4..e898e202 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository import dev.krtirtho.spotube.core.db.Database +import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService import dev.krtirtho.spotube.core.jam.JamSessionService @@ -219,6 +220,7 @@ val sharedModules = module { createdAtStart() } single { JamSessionService(get(), get()) } + singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } single { DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt index 7c5d878b..1c6b0a22 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -23,6 +23,7 @@ import com.appstractive.dnssd.createNetService import com.appstractive.dnssd.discoverServices import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlin.text.decodeToString data class DiscoveredDevice( val name: String, @@ -54,7 +55,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), ) DiscoveryState.Discovered(device = device, resolve = event.resolve) } @@ -65,7 +66,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), ) DiscoveryState.Resolved(device = device) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt new file mode 100644 index 00000000..7ecb8ff9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt @@ -0,0 +1,105 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.jam + +import io.ktor.http.decodeURLQueryComponent +import io.ktor.http.encodeURLParameter + +/** + * SDP payloads exchanged between jam peers are wrapped into `spotube://` deep links + * so they can be shared through any messaging medium. The SDP blob is percent-encoded + * as a query parameter. + * + * Host invite : `spotube://jam/invite?name=&sdp=` + * Guest answer : `spotube://jam/answer?name=&sdp=` + */ +sealed interface JamInviteLink { + val peerName: String + val sdp: String + + data class HostInvite( + override val peerName: String, + override val sdp: String, + ) : JamInviteLink + + data class GuestAnswer( + override val peerName: String, + override val sdp: String, + ) : JamInviteLink +} + +object JamInviteCodec { + const val SCHEME = "spotube" + const val INVITE_PATH = "jam/invite" + const val ANSWER_PATH = "jam/answer" + + fun buildHostInvite(hostName: String, offerSdp: String): String = + buildLink(INVITE_PATH, hostName, offerSdp) + + fun buildGuestAnswer(guestName: String, answerSdp: String): String = + buildLink(ANSWER_PATH, guestName, answerSdp) + + private fun buildLink(path: String, peerName: String, sdp: String): String = + "$SCHEME://$path?name=${peerName.encodeURLParameter()}" + + "&sdp=${sdp.encodeURLParameter()}" + + /** + * Parses a `spotude://jam/...` link. Returns null for foreign or malformed URIs. + * Parsing is done manually — generic URI parsers normalize unknown schemes in + * ways that mangle percent-encoded multi-line payloads. + */ + fun parse(rawUri: String): JamInviteLink? { + val uri = rawUri.trim() + if (!uri.startsWith("$SCHEME://", ignoreCase = true)) return null + + val withoutScheme = uri.substring(SCHEME.length + 3) + val queryStart = withoutScheme.indexOf('?') + if (queryStart < 0) return null + + val path = withoutScheme.take(queryStart).trim('/').lowercase() + val params = withoutScheme.substring(queryStart + 1) + .split('&') + .mapNotNull { pair -> + val separator = pair.indexOf('=') + if (separator <= 0) return@mapNotNull null + pair.take(separator) to pair.substring(separator + 1) + } + .toMap() + + val sdp = params["sdp"]?.decodeURLQueryComponent()?.takeIf { it.isNotBlank() } + ?: return null + val peerName = params["name"]?.decodeURLQueryComponent().orEmpty() + + return when (path) { + INVITE_PATH -> JamInviteLink.HostInvite(peerName, sdp) + ANSWER_PATH -> JamInviteLink.GuestAnswer(peerName, sdp) + else -> null + } + } + + /** + * Extracts an SDP payload from user input which may either be a full + * `spotube://` deep link or a raw SDP body pasted by hand. + */ + fun extractSdp(rawInput: String): String? { + val input = rawInput.trim() + parse(input)?.let { return it.sdp } + // Heuristic for raw SDP: first line is the session description header + return if (input.startsWith("v=", ignoreCase = false)) input else null + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt index f90a91a1..c85db931 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -38,6 +39,16 @@ import uniffi.compose_app.WebrtcEventHandler import uniffi.compose_app.WebrtcPeerConnection import uniffi.compose_app.createWebrtcPeerConnection +/** + * An invite generated by the host for one guest slot. The [sdp] offer is shared + * via a deep link; once the guest's answer comes back, [JamSessionService.acceptAnswer] + * completes the handshake for that slot. + */ +data class JamInvite( + val id: String, + val sdp: String, +) + class JamSessionService( private val audioPlayer: AudioPlayerInterface, private val settingsProvider: SettingsProvider, @@ -66,100 +77,109 @@ class JamSessionService( private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) val incomingMessages = _incomingMessages.asSharedFlow() - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val _incomingSuggestions = MutableSharedFlow(extraBufferCapacity = 32) val incomingSuggestions = _incomingSuggestions.asSharedFlow() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** Host side: invites whose answers have not arrived yet. */ + private val pendingInvites = mutableMapOf() + + /** Host side: guests whose handshake completed. Keyed by invite id. */ + private val connectedGuests = mutableMapOf() + + /** Guest side: the single connection to the host. */ private var hostConnection: WebrtcPeerConnection? = null - private val guestConnections = mutableMapOf() - private val guestLabels = mutableMapOf() - - private val eventHandler = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) { - // No-op in non-trickle mode: candidates are bundled into SDP - } - - override fun onIceGatheringStateChange(state: String) { - log.d { "ICE gathering state: $state" } - } - - override fun onConnectionStateChange(state: String) { - log.i { "Connection state: $state" } - } - - override fun onDataChannelOpen(label: String) { - log.i { "Data channel '$label' open" } - } - - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(label, data) - } - - override fun onDataChannelClose(label: String) { - log.i { "Data channel '$label' closed" } - } - } suspend fun createSession(): String { log.i { "Creating jam session" } - val settings = settingsProvider.settingsState.first() - val participantName = settings?.jamParticipantName?.ifBlank { - "Host-${randomShortId()}" - } ?: "Host" + val hostName = resolveParticipantName(defaultPrefix = "Host") - val pc = createWebrtcPeerConnection( - iceServers = listOf( - IceServerConfig( - urls = listOf("stun:stun.l.google.com:19302"), - username = "", - credential = "", - ) - ), - handler = eventHandler, - ) - - hostConnection = pc _role.value = JamRole.Host _localParticipantId.value = "host" _participants.value = listOf( JamParticipant( id = "host", - displayName = participantName, + displayName = hostName, isHost = true, ) ) _isActive.value = true - pc.createDataChannel("jam") - val offer = pc.createOffer() - log.i { "Generated SDP offer (length=${offer.length})" } - return offer + return generateInvite().sdp } - suspend fun acceptGuestAnswer(guestId: String, answer: String) { - val pc = guestConnections[guestId] ?: run { - log.w { "acceptGuestAnswer: no connection for $guestId" } - return + /** + * Generates a new invite (peer connection + SDP offer with bundled ICE candidates). + * Each invite admits exactly one guest. + */ + suspend fun generateInvite(): JamInvite { + if (_role.value != JamRole.Host) { + error("generateInvite can only be called by the host") } - pc.setRemoteAnswer(answer) - } - - suspend fun joinSession(offer: String): String { - log.i { "Joining jam session" } - val settings = settingsProvider.settingsState.first() - val participantName = settings?.jamParticipantName?.ifBlank { - "Guest-${randomShortId()}" - } ?: "Guest" + val inviteId = "guest-${randomShortId()}" + log.i { "Generating invite $inviteId" } val pc = createWebrtcPeerConnection( - iceServers = listOf( - IceServerConfig( - urls = listOf("stun:stun.l.google.com:19302"), - username = "", - credential = "", - ) - ), + iceServers = listOf(defaultIceServer()), + handler = guestEventHandler(inviteId), + ) + + pc.createDataChannel(CHANNEL_LABEL) + val offer = pc.createOffer() + pendingInvites[inviteId] = pc + return JamInvite(id = inviteId, sdp = offer) + } + + /** + * Completes a guest's handshake: applies their SDP answer to the peer connection + * created for [inviteId] and adds them to the participant list. + * + * When [inviteId] is null, the oldest still-pending invite is used — convenient + * when an answer deep link arrives out of band. + * + * @param answerSdp raw SDP answer body (not a deep link). + * @param peerName display name of the guest, taken from their answer link if available. + */ + suspend fun acceptAnswer(inviteId: String?, answerSdp: String, peerName: String): Boolean { + if (_role.value != JamRole.Host) { + log.w { "acceptAnswer ignored: not hosting" } + return false + } + val resolvedId = inviteId ?: pendingInvites.keys.firstOrNull() + if (resolvedId == null) { + log.w { "acceptAnswer: no pending invite" } + return false + } + val pc = pendingInvites.remove(resolvedId) + if (pc == null) { + log.w { "acceptAnswer: no pending invite '$resolvedId'" } + return false + } + runCatching { pc.setRemoteAnswer(answerSdp) } + .onFailure { e -> + log.w(e) { "Failed to apply answer for $inviteId" } + scope.launch { runCatching { pc.shutdown() } } + return false + } + connectedGuests[resolvedId] = pc + _participants.update { current -> + current + JamParticipant( + id = resolvedId, + displayName = peerName.ifBlank { "Guest-${resolvedId.takeLast(4)}" }, + isHost = false, + ) + } + log.i { "Guest $resolvedId ($peerName) joined" } + return true + } + + suspend fun joinSession(offerSdp: String): String { + log.i { "Joining jam session" } + val participantName = resolveParticipantName(defaultPrefix = "Guest") + + val pc = createWebrtcPeerConnection( + iceServers = listOf(defaultIceServer()), handler = eventHandler, ) @@ -168,65 +188,31 @@ class JamSessionService( _localParticipantId.value = "guest" _isActive.value = true - pc.setRemoteOffer(offer) - pc.createDataChannel("jam") + // The data channel arrives in-band from the host's offer via on_data_channel; + // we only answer here. + pc.setRemoteOffer(offerSdp) val answer = pc.createAnswer() log.i { "Generated SDP answer (length=${answer.length})" } return answer } - suspend fun hostAdmitGuest(guestOffer: String): String { - if (_role.value != JamRole.Host) { - error("hostAdmitGuest can only be called by the host") - } - val guestId = "guest-${guestConnections.size + 1}" - log.i { "Admitting guest $guestId" } - - val handler = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) {} - override fun onIceGatheringStateChange(state: String) {} - override fun onConnectionStateChange(state: String) {} - override fun onDataChannelOpen(label: String) {} - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(label, data, guestId) - } - override fun onDataChannelClose(label: String) {} - } - - val pc = createWebrtcPeerConnection( - iceServers = listOf( - IceServerConfig( - urls = listOf("stun:stun.l.google.com:19302"), - username = "", - credential = "", - ) - ), - handler = handler, - ) - guestConnections[guestId] = pc - guestLabels[guestId] = "jam-$guestId" - - pc.setRemoteOffer(guestOffer) - pc.createDataChannel("jam-${guestId}") - val answer = pc.createAnswer() - return answer - } - suspend fun sendMessage(message: JamMessage, guestId: String? = null) { - val json = json.encodeToString(JamMessage.serializer(), message) + val payload = json.encodeToString(JamMessage.serializer(), message) when (_role.value) { JamRole.Host -> { - if (guestId != null) { - guestConnections[guestId]?.sendData("jam-$guestId", json) + val targets = if (guestId != null) { + listOfNotNull(connectedGuests[guestId]) } else { - guestConnections.forEach { (id, pc) -> - pc.sendData("jam-$id", json) - } + connectedGuests.values.toList() + } + targets.forEach { pc -> + runCatching { pc.sendData(CHANNEL_LABEL, payload) } + .onFailure { e -> log.w(e) { "Failed to send to guest" } } } } JamRole.Guest -> { - hostConnection?.sendData("jam", json) + hostConnection?.sendData(CHANNEL_LABEL, payload) } null -> log.w { "sendMessage called while no session is active" } @@ -236,46 +222,13 @@ class JamSessionService( suspend fun leave() { log.i { "Leaving jam session" } runCatching { sendMessage(JamMessage.Leave()) } - hostConnection?.shutdown() - guestConnections.values.forEach { runCatching { it.shutdown() } } - hostConnection = null - guestConnections.clear() - guestLabels.clear() + shutdownAll() _role.value = null _participants.value = emptyList() _isActive.value = false _localParticipantId.value = null } - private fun handleIncomingMessage(label: String, data: String, fromGuestId: String? = null) { - try { - val message = json.decodeFromString(JamMessage.serializer(), data) - _incomingMessages.tryEmit(message) - when (message) { - is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> { - _incomingSuggestions.tryEmit(message) - } - - is JamMessage.Leave -> { - if (_role.value == JamRole.Host && fromGuestId != null) { - val leavingPc = guestConnections.remove(fromGuestId) - guestLabels.remove(fromGuestId) - scope.launch { - runCatching { leavingPc?.shutdown() } - } - _participants.update { current -> - current.filterNot { it.id == fromGuestId } - } - } - } - - else -> Unit - } - } catch (e: Exception) { - log.w(e) { "Failed to parse jam message on $label" } - } - } - suspend fun broadcastPlaybackCommand(command: PlaybackCmd) { if (_role.value != JamRole.Host) return sendMessage(JamMessage.PlaybackCommand(command)) @@ -300,8 +253,113 @@ class JamSessionService( if (_role.value != JamRole.Guest) return sendMessage(JamMessage.SuggestPlaylist(tracks)) } + + private fun defaultIceServer() = IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + + private suspend fun resolveParticipantName(defaultPrefix: String): String { + val settings = settingsProvider.settingsState.first() + return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" } + ?: "$defaultPrefix-${randomShortId()}" + } + + /** + * Per-guest handler so messages received on a guest's connection can be + * attributed back to that guest (needed for kick-on-leave and targeted sends). + */ + private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) {} + + override fun onIceGatheringStateChange(state: String) { + log.d { "[$guestId] ICE gathering state: $state" } + } + + override fun onConnectionStateChange(state: String) { + log.i { "[$guestId] Connection state: $state" } + } + + override fun onDataChannelOpen(label: String) { + log.i { "[$guestId] Data channel '$label' open" } + } + + override fun onDataChannelMessage(label: String, data: String) { + handleIncomingMessage(data, fromGuestId = guestId) + } + + override fun onDataChannelClose(label: String) { + log.i { "[$guestId] Data channel closed" } + } + } + + private val eventHandler = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) {} + + override fun onIceGatheringStateChange(state: String) { + log.d { "ICE gathering state: $state" } + } + + override fun onConnectionStateChange(state: String) { + log.i { "Connection state: $state" } + } + + override fun onDataChannelOpen(label: String) { + log.i { "Data channel '$label' open" } + } + + override fun onDataChannelMessage(label: String, data: String) { + handleIncomingMessage(data, fromGuestId = null) + } + + override fun onDataChannelClose(label: String) { + log.i { "Data channel closed" } + } + } + + private fun handleIncomingMessage(data: String, fromGuestId: String?) { + try { + val message = json.decodeFromString(JamMessage.serializer(), data) + _incomingMessages.tryEmit(message) + when (message) { + is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> { + _incomingSuggestions.tryEmit(message) + } + + is JamMessage.Leave -> { + if (_role.value == JamRole.Host && fromGuestId != null) { + val leavingPc = connectedGuests.remove(fromGuestId) + scope.launch { + runCatching { leavingPc?.shutdown() } + } + _participants.update { current -> + current.filterNot { it.id == fromGuestId } + } + } else if (_role.value == JamRole.Guest) { + scope.launch { leave() } + } + } + + else -> Unit + } + } catch (e: Exception) { + log.w(e) { "Failed to parse jam message" } + } + } + + private suspend fun shutdownAll() { + pendingInvites.values.forEach { runCatching { it.shutdown() } } + connectedGuests.values.forEach { runCatching { it.shutdown() } } + runCatching { hostConnection?.shutdown() } + pendingInvites.clear() + connectedGuests.clear() + hostConnection = null + } } +private const val CHANNEL_LABEL = "jam" + private fun MutableStateFlow.update(transform: (T) -> T) { value = transform(value) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index 93b3ca37..26280783 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -19,12 +19,17 @@ package dev.krtirtho.spotube.modules.jam import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton @@ -32,15 +37,20 @@ import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import org.koin.compose.viewmodel.koinViewModel @@ -50,17 +60,7 @@ fun JamScreen( navigationCommands: NavigationCommands, ) { val viewModel = koinViewModel() - val isActive by viewModel.isActive.collectAsStateWithLifecycle() - val pendingOffer by viewModel.pendingHostOffer.collectAsStateWithLifecycle() - val pendingAnswer by viewModel.pendingGuestAnswer.collectAsStateWithLifecycle() - val error by viewModel.error.collectAsStateWithLifecycle() - - LaunchedEffect(isActive) { - if (isActive && navigationCommands != null) { - // navigationCommands doesn't navigate here automatically; - // the session screen is the same screen so we just stay. - } - } + val state by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -74,49 +74,70 @@ fun JamScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp), + .padding(16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (error != null) { - Text( - text = error ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) + state.error?.let { error -> + ErrorBanner(text = error, onDismiss = viewModel::clearError) } - if (pendingOffer == null && pendingAnswer == null) { - CreateOrJoinView( - onCreate = { viewModel.createSession() }, - onJoin = { offer -> viewModel.joinSession(offer) }, + when { + !state.isActive && state.incomingOfferSdp != null -> IncomingInviteView( + hostName = state.incomingHostName.orEmpty(), + onJoin = viewModel::joinWithIncomingInvite, + onDismiss = viewModel::dismissIncomingInvite, ) - } else if (pendingOffer != null) { - HostOfferView( - offer = pendingOffer!!, - onLeave = { viewModel.leave() }, + + !state.isActive -> CreateOrJoinView( + onCreate = viewModel::createSession, + onJoin = viewModel::joinWithPasted, ) - } else if (pendingAnswer != null) { - GuestAnswerView( - answer = pendingAnswer!!, - onLeave = { viewModel.leave() }, + + state.role == JamRole.Host -> HostSessionView( + state = state, + onNewInvite = viewModel::generateNewInvite, + onSubmitAnswer = viewModel::submitAnswerPasted, + onShare = viewModel::share, + onLeave = viewModel::leave, + ) + + else -> GuestSessionView( + state = state, + onShare = viewModel::share, + onLeave = viewModel::leave, ) } } } } +@Composable +private fun ErrorBanner(text: String, onDismiss: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = text, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton(onClick = onDismiss) { + Text("Dismiss") + } + } +} + @Composable private fun CreateOrJoinView( onCreate: () -> Unit, onJoin: (String) -> Unit, ) { var tab by remember { mutableIntStateOf(0) } - var offer by remember { mutableStateOf("") } + var pasted by rememberSaveable { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = "Listen Together with friends", - style = MaterialTheme.typography.titleLarge, + text = "Listen together with friends over a peer-to-peer connection.", + style = MaterialTheme.typography.titleMedium, ) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { @@ -135,8 +156,10 @@ private fun CreateOrJoinView( if (tab == 0) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Create a new jam session. You'll be the host and can control playback. Share the SDP offer with your friends so they can join.", + text = "Start a session as the host. You'll get a shareable invite link " + + "to send to friends; when they accept, they appear here.", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button(onClick = onCreate) { Text("Create Session") @@ -145,22 +168,24 @@ private fun CreateOrJoinView( } else { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Paste the SDP offer from the host below. You'll get an SDP answer to send back.", + text = "Paste the invite link the host shared with you.", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( - value = offer, - onValueChange = { offer = it }, + value = pasted, + onValueChange = { pasted = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Host's SDP offer") }, - minLines = 3, + label = { Text("Invite link") }, + placeholder = { Text("spotube://jam/invite?...") }, + minLines = 2, maxLines = 6, ) Button( - onClick = { onJoin(offer.trim()) }, - enabled = offer.isNotBlank(), + onClick = { onJoin(pasted) }, + enabled = pasted.isNotBlank(), ) { - Text("Generate Answer") + Text("Join Session") } } } @@ -168,60 +193,196 @@ private fun CreateOrJoinView( } @Composable -private fun HostOfferView( - offer: String, - onLeave: () -> Unit, +private fun IncomingInviteView( + hostName: String, + onJoin: () -> Unit, + onDismiss: () -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Session created. Send this SDP offer to your friends:", - style = MaterialTheme.typography.bodyMedium, + text = "$hostName invited you to a jam session", + style = MaterialTheme.typography.titleMedium, ) - SelectionContainer { - OutlinedTextField( - value = offer, - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - label = { Text("SDP Offer (copy and send to guests)") }, - minLines = 4, - maxLines = 10, - ) - } - Text( - text = "When a guest responds with an SDP answer, use the JamSessionScreen to add them.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = onLeave) { - Text("Leave Session") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onJoin) { + Text("Join") + } + OutlinedButton(onClick = onDismiss) { + Text("Ignore") + } } } } @Composable -private fun GuestAnswerView( - answer: String, +private fun HostSessionView( + state: JamUiState, + onNewInvite: () -> Unit, + onSubmitAnswer: (String) -> Unit, + onShare: (String) -> Unit, onLeave: () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + val clipboard = LocalClipboardManager.current + var pastedAnswer by rememberSaveable { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ParticipantsSection(state.participants) + + HorizontalDivider() + Text( - text = "You've joined the session. Send this SDP answer back to the host:", - style = MaterialTheme.typography.bodyMedium, + text = "Invite someone", + style = MaterialTheme.typography.titleSmall, ) + val inviteLink = state.inviteLink + if (inviteLink != null) { + ShareableLinkBox( + label = "Invite link", + link = inviteLink, + onCopy = { clipboard.setText(AnnotatedString(inviteLink)) }, + onShare = { onShare(inviteLink) }, + ) + } + OutlinedButton(onClick = onNewInvite) { + Text("Generate new invite") + } + + HorizontalDivider() + + Text( + text = "Accept a guest's answer", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "When your guest sends back their answer link, paste it below.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = pastedAnswer, + onValueChange = { pastedAnswer = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Answer link or SDP") }, + minLines = 2, + maxLines = 6, + ) + Button( + onClick = { + onSubmitAnswer(pastedAnswer) + pastedAnswer = "" + }, + enabled = pastedAnswer.isNotBlank(), + ) { + Text("Accept Answer") + } + + LeaveButton(onLeave) + } +} + +@Composable +private fun GuestSessionView( + state: JamUiState, + onShare: (String) -> Unit, + onLeave: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ParticipantsSection(state.participants) + + val answerLink = state.answerLink + if (answerLink == null) { + Text( + text = "Connecting to the session...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "Almost there! Send your answer back to the host:", + style = MaterialTheme.typography.titleSmall, + ) + ShareableLinkBox( + label = "Answer link", + link = answerLink, + onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, + onShare = { onShare(answerLink) }, + ) + } + + LeaveButton(onLeave) + } +} + +@Composable +private fun ParticipantsSection(participants: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Participants (${participants.size})", + style = MaterialTheme.typography.titleSmall, + ) + participants.forEach { participant -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = participant.displayName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (participant.isHost) { + Text( + text = "Host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +@Composable +private fun ShareableLinkBox( + label: String, + link: String, + onCopy: () -> Unit, + onShare: () -> Unit, +) { + val viewModel: JamViewModel = koinViewModel() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SelectionContainer { OutlinedTextField( - value = answer, + value = link, onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), - label = { Text("SDP Answer (copy and send to host)") }, - minLines = 4, - maxLines = 10, + label = { Text(label) }, + minLines = 2, + maxLines = 6, ) } - Button(onClick = onLeave) { - Text("Leave Session") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onCopy) { + Text("Copy") + } + if (viewModel.supportsNativeShare) { + OutlinedButton(onClick = onShare) { + Text("Share") + } + } } } +} + +@Composable +private fun LeaveButton(onLeave: () -> Unit) { + OutlinedButton(onClick = onLeave) { + Text("Leave Session") + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt index 10500045..2033c322 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -19,67 +19,205 @@ package dev.krtirtho.spotube.modules.jam import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.jam.JamMessage +import dev.krtirtho.spotube.PlatformType +import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService +import dev.krtirtho.spotube.core.jam.JamInviteCodec +import dev.krtirtho.spotube.core.jam.JamInviteLink import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.share.ShareService +import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -class JamViewModel : ViewModel(), KoinComponent { - private val jamSession: JamSessionService by inject() - private val audioPlayer: AudioPlayerInterface by inject() +data class JamUiState( + val isActive: Boolean = false, + val role: JamRole? = null, + val participants: List = emptyList(), + /** Host: deep link containing this session's SDP offer, ready to share. */ + val inviteLink: String? = null, + /** Guest: deep link containing our SDP answer, to send back to the host. */ + val answerLink: String? = null, + /** Guest: offer received via deep link (or paste), waiting for confirmation. */ + val incomingHostName: String? = null, + val incomingOfferSdp: String? = null, + val error: String? = null, +) - val role: StateFlow = jamSession.role - val participants: StateFlow> = jamSession.participants - val isActive: StateFlow = jamSession.isActive +class JamViewModel( + private val jamSession: JamSessionService, + private val deepLinks: JamDeepLinkService, + private val shareService: ShareService, + private val settingsProvider: SettingsProvider, +) : ViewModel() { - private val _pendingHostOffer = MutableStateFlow(null) - val pendingHostOffer: StateFlow = _pendingHostOffer.asStateFlow() + private val _uiState = MutableStateFlow(JamUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _pendingGuestAnswer = MutableStateFlow(null) - val pendingGuestAnswer: StateFlow = _pendingGuestAnswer.asStateFlow() + val supportsNativeShare: Boolean = + getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() - - fun createSession() { + init { viewModelScope.launch { - try { - val offer = jamSession.createSession() - _pendingHostOffer.value = offer - } catch (e: Exception) { - _error.value = "Failed to create session: ${e.message}" + // Mirror live session state into the UI state. + jamSession.isActive.collect { active -> + _uiState.update { + it.copy( + isActive = active, + role = jamSession.role.value, + participants = jamSession.participants.value, + inviteLink = if (!active) null else it.inviteLink, + answerLink = if (!active) null else it.answerLink, + incomingOfferSdp = if (!active) it.incomingOfferSdp else null, + incomingHostName = if (!active) it.incomingHostName else null, + ) + } + } + } + viewModelScope.launch { + jamSession.participants.collect { participants -> + _uiState.update { it.copy(participants = participants) } + } + } + viewModelScope.launch { + deepLinks.pendingLink.collect { link -> + handleDeepLink(link) } } } - fun joinSession(offer: String) { + fun createSession() { viewModelScope.launch { - try { - val answer = jamSession.joinSession(offer) - _pendingGuestAnswer.value = answer - } catch (e: Exception) { - _error.value = "Failed to join session: ${e.message}" + runCatching { + val offer = jamSession.createSession() + JamInviteCodec.buildHostInvite(localName(), offer) + }.onSuccess { link -> + _uiState.update { it.copy(inviteLink = link, error = null) } + }.onFailure { e -> + _uiState.update { it.copy(error = "Failed to create session: ${e.message}") } } } } + fun generateNewInvite() { + viewModelScope.launch { + runCatching { + val invite = jamSession.generateInvite() + JamInviteCodec.buildHostInvite(localName(), invite.sdp) + }.onSuccess { link -> + _uiState.update { it.copy(inviteLink = link, error = null) } + }.onFailure { e -> + _uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") } + } + } + } + + fun joinWithIncomingInvite() { + val sdp = _uiState.value.incomingOfferSdp ?: return + join(sdp) + } + + fun joinWithPasted(input: String) { + val sdp = JamInviteCodec.extractSdp(input) + if (sdp == null) { + _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") } + return + } + join(sdp) + } + + /** + * Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link. + */ + fun submitAnswerPasted(input: String) { + when (val parsed = JamInviteCodec.parse(input.trim())) { + is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName) + else -> { + val sdp = JamInviteCodec.extractSdp(input) + if (sdp == null) { + _uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") } + } else { + acceptAnswerInternal(sdp, "") + } + } + } + } + + fun share(text: String) { + shareService.share(text, "Spotube Group Jam") + } + fun leave() { viewModelScope.launch { jamSession.leave() - _pendingHostOffer.value = null - _pendingGuestAnswer.value = null + deepLinks.clear() + _uiState.update { + JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName) + } } } fun clearError() { - _error.value = null + _uiState.update { it.copy(error = null) } } + + fun dismissIncomingInvite() { + deepLinks.clear() + _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) } + } + + private fun join(offerSdp: String) { + viewModelScope.launch { + runCatching { + val answer = jamSession.joinSession(offerSdp) + JamInviteCodec.buildGuestAnswer(localName(), answer) + }.onSuccess { link -> + _uiState.update { + it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null) + } + }.onFailure { e -> + _uiState.update { it.copy(error = "Failed to join session: ${e.message}") } + } + } + } + + private fun acceptAnswerInternal(answerSdp: String, peerName: String) { + viewModelScope.launch { + val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) } + .getOrDefault(false) + if (!accepted) { + _uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") } + } else { + _uiState.update { it.copy(error = null) } + } + } + } + + private suspend fun handleDeepLink(link: JamInviteLink?) { + when (link) { + is JamInviteLink.HostInvite -> { + if (!jamSession.isActive.value) { + _uiState.update { + it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp) + } + } + } + + is JamInviteLink.GuestAnswer -> { + if (jamSession.role.value == JamRole.Host) { + acceptAnswerInternal(link.sdp, link.peerName) + } + } + + null -> Unit + } + } + + private fun localName(): String = + settingsProvider.settingsState.value?.jamParticipantName.orEmpty() } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 3e597a48..171f215b 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState import com.sun.jna.Library import com.sun.jna.Native import dev.krtirtho.spotube.core.di.initKoin +import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.systemtray.SystemTray @@ -81,9 +82,28 @@ private fun disableWebKitGpuCompositing() { LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1) } +/** + * Routes `spotube://` deep links into [ExternalUriHandler]. + * macOS delivers them through the open-URI handler; on Linux/Windows they arrive + * as command line arguments (scheme registration is handled by the distribution + * packaging, e.g. the `.desktop` file's `Exec %u`). + */ +private fun handleStartupDeepLinks(args: Array) { + runCatching { + if (java.awt.Desktop.isDesktopSupported()) { + java.awt.Desktop.getDesktop().setOpenURIHandler { event -> + ExternalUriHandler.onNewUri(event.uri.toString()) + } + } + } + args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) } + ?.let(ExternalUriHandler::onNewUri) +} + @OptIn(ExperimentalComposeUiApi::class) -fun main() { +fun main(args: Array) { disableWebKitGpuCompositing() + handleStartupDeepLinks(args) FileKit.init(appId = "dev.krtirtho.spotube") initKoin() NewPipeDownloader.init(KoinPathsProvider.paths) diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 870b2c71..a07e2e68 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,17 @@ CADisableMinimumFrameDurationOnPhone + CFBundleURLTypes + + + CFBundleURLName + dev.krtirtho.spotube + CFBundleURLSchemes + + spotube + + + NSLocalNetworkUsageDescription Required to discover local network devices NSBonjourServices diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 08b58d47..f0058387 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -20,6 +20,9 @@ struct iOSApp: App { var body: some Scene { WindowGroup { ContentView() + .onOpenURL { url in + ExternalUriHandler.shared.onNewUri(uri: url.absoluteString) + } } } } \ No newline at end of file From 1aeee1db79d77120525fc33b57f3006e121ef54a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 28 Aug 2026 13:18:04 +0600 Subject: [PATCH 004/132] fix(sidebar): add spacer to AppSidebar for improved layout --- .../kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt | 2 ++ gradle/libs.versions.toml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 2cbdfe0d..0c4a3c78 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -194,6 +194,8 @@ fun AppSidebar( selected = false, expanded = expanded, ) + + Spacer(modifier = Modifier.height(120.dp)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c59d3c8..b515031d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ androidx-activity = "1.13.0" androidx-appcompat = "1.7.1" androidx-core = "1.19.0" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.11.0" +androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" appdirs = "1.5.0" cache4k = "0.14.0" @@ -62,8 +62,8 @@ compose-webview = "1.0.1" composeNativeTray = "2.0.3" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" -compose-multiplatform-adaptive = "1.3.0-beta02" -compose-multiplatform-lifecycle = "2.11.0" +compose-multiplatform-adaptive = "1.3.0-alpha05" +compose-multiplatform-lifecycle = "2.10.0" feather-icons = "1.1.1" material3-window-size = "1.9.0" kotlinx-datetime = "0.8.0" From df3a6dcbd275299888ba1e591f7f5d92fe5ea461 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 28 Aug 2026 18:28:24 +0600 Subject: [PATCH 005/132] feat(remote-control): implement remote playback functionality and UI components --- .../dev/krtirtho/spotube/MainActivity.kt | 13 +- .../dev/krtirtho/spotube/MyApplication.kt | 11 +- .../spotube/core/di/Modules.android.kt | 2 +- .../LocalNetworkPermission.android.kt | 50 +++ .../spotube/core/paths/Paths.android.kt | 16 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 9 +- .../core/discovery/DeviceDiscoveryService.kt | 3 +- .../core/discovery/LocalNetworkPermission.kt | 28 ++ .../core/navigation/NavigationModule.kt | 9 + .../core/remote/RemoteControlClient.kt | 188 +++++++++ .../core/remote/RemoteControlHandler.kt | 8 + .../core/remote/RemoteControlProtocol.kt | 8 + .../core/remote/RemoteControlService.kt | 83 +++- .../core/remote/RemotePlaybackController.kt | 117 ++++++ .../spotube/core/server/LocalServer.kt | 10 +- .../spotube/modules/devices/DevicesScreen.kt | 218 +++++++--- .../modules/devices/DevicesViewModel.kt | 162 +++++++- .../modules/devices/PlayDestinationPicker.kt | 88 ++++ .../modules/devices/RemoteControlScreen.kt | 375 ++++++++++++++++++ .../modules/devices/RemoteControlViewModel.kt | 170 ++++++++ .../modules/playlist/PlaylistScreen.kt | 9 + .../modules/playlist/PlaylistViewModel.kt | 30 +- .../modules/settings/SettingsScreen.kt | 3 + .../settings/sections/PlaybackSection.kt | 6 + .../discovery/LocalNetworkPermission.ios.kt | 24 ++ .../discovery/LocalNetworkPermission.jvm.kt | 24 ++ 26 files changed, 1573 insertions(+), 91 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt create mode 100644 composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt index 4734a6f8..55ab3202 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -25,6 +26,7 @@ import androidx.activity.enableEdgeToEdge import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.paths.Paths +import dev.krtirtho.spotube.media.PlaybackService import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init @@ -33,7 +35,16 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) FileKit.init(this) - NewPipeDownloader.init(Paths(this)) + NewPipeDownloader.init(Paths()) + + // Start PlaybackService from Activity context (allowed on Android 12+) + val serviceIntent = Intent(this, PlaybackService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + intent?.dataString?.let(ExternalUriHandler::onNewUri) setContent { App() diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt index 306e4433..d93242aa 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt @@ -18,10 +18,8 @@ package dev.krtirtho.spotube import android.app.Application -import android.content.Intent -import android.os.Build import dev.krtirtho.spotube.core.di.initKoin -import dev.krtirtho.spotube.media.PlaybackService +import dev.krtirtho.spotube.core.paths.Paths import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent { override fun onCreate() { super.onCreate() + Paths.init(this) initKoin { androidContext(this@MyApplication) } - val intent = Intent(this, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(intent) - } else { - startService(intent) - } } override fun onTerminate() { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt index 1ee43348..be29dba7 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt @@ -29,7 +29,7 @@ import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscove import org.koin.dsl.module actual val platformModules = module { - single { Paths(get()) } + single { Paths() } single { AudioPlayer(get()) } single { AndroidLocalMediaDiscoveryService(get()) } single { AndroidShareService(get()) } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt new file mode 100644 index 00000000..961a6b7c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -0,0 +1,50 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.discovery + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +/** + * On Android 13+ (and especially 16+ where it became a runtime permission), + * mDNS/NSD discovery requires `NEARBY_WIFI_DEVICES`. Requests it when the + * returned lambda is invoked; the caller decides when (e.g. first visit to the + * Devices screen). + */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* result is picked up by discovery/advertising retry loops */ } + + return { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt index e8f172ce..20c187a0 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt @@ -20,9 +20,10 @@ package dev.krtirtho.spotube.core.paths import android.content.Context import android.os.Environment -actual class Paths( - val context: Context -) { +actual class Paths { + private val context: Context + get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" } + actual fun getApplicationCacheDirPath(): String { return context.cacheDir.absolutePath } @@ -38,4 +39,13 @@ actual class Paths( actual fun getMusicCacheDirPath(): String { return context.cacheDir.absolutePath + "/music_cache" } + + companion object { + @Volatile + private var appContext: Context? = null + + fun init(context: Context) { + appContext = context.applicationContext + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index e898e202..a2b6fe98 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -28,8 +28,10 @@ import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.navigation.navigationModule +import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.core.remote.RemoteControlService +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager @@ -44,6 +46,7 @@ import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel import dev.krtirtho.spotube.modules.devices.DevicesViewModel +import dev.krtirtho.spotube.modules.devices.RemoteControlViewModel import dev.krtirtho.spotube.modules.jam.JamViewModel import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel @@ -140,6 +143,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -175,7 +179,8 @@ val sharedModules = module { // Blacklist singleOf(::BlacklistRepository) viewModelOf(::BlacklistViewModel) - viewModelOf(::DevicesViewModel) + viewModel { DevicesViewModel(get()) } + viewModelOf(::RemoteControlViewModel) viewModelOf(::JamViewModel) // Album @@ -215,10 +220,12 @@ val sharedModules = module { createdAtStart() } single { RemoteControlHandler(get(), get(), get()) } + single { RemoteControlClient() } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } + single { RemotePlaybackController() } single { JamSessionService(get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt index 1c6b0a22..04221d6a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -88,6 +88,7 @@ class DeviceDiscoveryService { name: String, port: Int, deviceId: String, + registerTimeoutMs: Long = 5_000, ): NetService { val service = createNetService( type = SERVICE_TYPE, @@ -95,7 +96,7 @@ class DeviceDiscoveryService { port = port, txt = mapOf(TXT_DEVICE_ID to deviceId), ) - service.register() + service.register(timeoutInMs = registerTimeoutMs) return service } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt new file mode 100644 index 00000000..404c2b0d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt @@ -0,0 +1,28 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** + * Returns a lambda that requests the OS permission needed for local network + * discovery (mDNS/NSD). No-op on platforms where such a permission doesn't + * exist or is granted implicitly. + */ +@Composable +expect fun rememberLocalNetworkPermissionRequester(): () -> Unit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index 79f26337..bfea9743 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.modules.album.AlbumScreen import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen import dev.krtirtho.spotube.modules.devices.DevicesScreen +import dev.krtirtho.spotube.modules.devices.RemoteControlScreen import dev.krtirtho.spotube.modules.jam.JamScreen import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.library.LibraryScreen @@ -78,6 +79,9 @@ sealed interface Routes : NavKey { @Serializable data object Blacklist : Routes + @Serializable + data object RemoteControl : Routes + @Serializable data object Devices : Routes @@ -159,6 +163,11 @@ val navigationModule = module { navigation { DevicesScreen(navigationCommands = get()) } + navigation { + RemoteControlScreen( + onDisconnect = { get().pop() } + ) + } navigation { JamScreen(navigationCommands = get()) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt new file mode 100644 index 00000000..e6d268f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -0,0 +1,188 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.remote + +import co.touchlab.kermit.Logger +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** + * WebSocket client for controlling a remote Spotube instance. + * Connects to the remote device's `/control` endpoint and sends commands. + */ +class RemoteControlClient { + private val logger = Logger.withTag("RemoteControlClient") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val httpClient = HttpClient { + install(WebSockets) + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 30_000 + } + } + + private var session: WebSocketSession? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _stateUpdates = MutableSharedFlow(extraBufferCapacity = 32) + val stateUpdates: SharedFlow = _stateUpdates.asSharedFlow() + + suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { + if (_connectionState.value is ConnectionState.Connected) { + logger.w { "Already connected" } + return + } + + _connectionState.value = ConnectionState.Connecting + try { + session = httpClient.webSocketSession { + url("ws://$host:$port/control") + header("X-Device-Id", deviceId) + header("X-Device-Name", deviceName) + } + + logger.i { "WebSocket connected to $host:$port, waiting for authorization..." } + + // Start receiving messages in a separate coroutine + scope.launch { + receiveLoop(host, port) + } + } catch (e: Exception) { + logger.e(e) { "Failed to connect to $host:$port" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection failed") + disconnect() + } + } + + private suspend fun receiveLoop(host: String, port: Int) { + val currentSession = session ?: return + try { + for (frame in currentSession.incoming) { + when (frame) { + is Frame.Text -> { + val text = frame.readText() + try { + val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + when (event) { + is RemoteControlEvent.Connected -> { + logger.i { "Connection authorized by server" } + _connectionState.value = ConnectionState.Connected(host, port) + } + is RemoteControlEvent.WaitingForPermission -> { + logger.i { "Waiting for permission: ${event.message}" } + // Keep showing connecting state + } + else -> { + // Only emit state updates after connection is established + if (_connectionState.value is ConnectionState.Connected) { + _stateUpdates.emit(event) + } + } + } + } catch (e: Exception) { + logger.w(e) { "Failed to parse message: $text" } + } + } + is Frame.Close -> { + logger.i { "WebSocket closed by server" } + _connectionState.value = ConnectionState.Disconnected + break + } + else -> {} + } + } + } catch (e: Exception) { + logger.e(e) { "Error in receive loop" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection lost") + } + } + + suspend fun sendCommand(command: RemoteControlCommand) { + val currentSession = session ?: run { + logger.w { "Not connected" } + return + } + + val envelope = CommandEnvelope( + commandId = randomShortId(), + command = command, + ) + + try { + val text = json.encodeToString(CommandEnvelope.serializer(), envelope) + currentSession.send(Frame.Text(text)) + logger.d { "Sent command: $command" } + } catch (e: Exception) { + logger.e(e) { "Failed to send command" } + _connectionState.value = ConnectionState.Error(e.message ?: "Send failed") + } + } + + suspend fun disconnect() { + session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) + session = null + _connectionState.value = ConnectionState.Disconnected + logger.i { "Disconnected" } + } + + private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } + } +} + +sealed interface ConnectionState { + data object Disconnected : ConnectionState + data object Connecting : ConnectionState + data class Connected(val host: String, val port: Int) : ConnectionState + data class Error(val message: String) : ConnectionState +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 15f24636..f192855a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -70,6 +70,12 @@ class RemoteControlHandler( val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices if (!isAllowed) { + // Send waiting for permission message + val waitingMessage = RemoteControlEvent.WaitingForPermission( + "Waiting for permission from $deviceName..." + ) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage))) + val request = ConnectionRequest( deviceId = deviceId ?: "unknown", deviceName = deviceName, @@ -92,6 +98,8 @@ class RemoteControlHandler( } } + // Send connected message + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected))) logger.i { "Remote control connection established from $deviceName ($deviceId)" } try { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index b9cacd5c..5e63d305 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -69,6 +69,14 @@ sealed class RemoteControlCommand { @Serializable sealed class RemoteControlEvent { + @Serializable + @SerialName("connected") + data object Connected : RemoteControlEvent() + + @Serializable + @SerialName("waitingForPermission") + data class WaitingForPermission(val message: String) : RemoteControlEvent() + @Serializable @SerialName("playerState") data class PlayerState( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt index 0e342da4..207e2f8b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -24,10 +24,18 @@ import dev.krtirtho.spotube.core.server.LocalServer import dev.krtirtho.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.random.Random @@ -36,6 +44,10 @@ import kotlin.random.Random * instances can discover and control it. Advertises only while the * "Allow remote control" setting is enabled and the local playback server is * listening on the LAN (0.0.0.0). + * + * Registration is retried with backoff: NsdManager is flaky right after a cold + * start, and a single registration attempt is bounded by a short timeout so a + * stalled platform callback can't wedge a dispatcher thread for long. */ class RemoteControlService( private val settingsRepository: SettingsRepository, @@ -45,9 +57,18 @@ class RemoteControlService( private val log = Logger.withTag("RemoteControlService") private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val _localDeviceId = MutableStateFlow("") + val localDeviceId: StateFlow = _localDeviceId.asStateFlow() + private var advertisedService: NetService? = null + private var registerJob: Job? = null init { + // Ensure a stable device id exists and is persisted up front, so discovery + // can reliably filter out this device's own advertisement. + scope.launch { + _localDeviceId.value = resolveDeviceId() + } scope.launch { combine( settingsRepository.userSettings, @@ -56,32 +77,74 @@ class RemoteControlService( .distinctUntilChanged() .collect { (settings, port) -> if (settings.allowRemoteControl && port != null) { - ensureAdvertised(settings.remoteControlDeviceName, port) + if (registerJob?.isActive != true) { + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } } else { + registerJob?.cancel() + registerJob = null stopAdvertising() } } } } - private suspend fun ensureAdvertised(name: String, port: Int) { + /** + * The service name this device advertises under, derived deterministically + * from settings so discovery can match it against the local advertisement. + */ + fun advertisedName(): String { + val deviceId = _localDeviceId.value.ifBlank { + settingsRepository.userSettings.value.remoteControlDeviceId + } + val configured = settingsRepository.userSettings.value.remoteControlDeviceName + return configured.ifBlank { "Spotube-${deviceId.take(6)}" } + } + + /** + * Kicks off (or restarts) the advertising loop. Used when the local-network + * permission is granted at runtime after earlier attempts failed. + */ + fun retryAdvertising() { + val settings = settingsRepository.userSettings.value + val port = localServer.port.value + if (!settings.allowRemoteControl || port == null) return + registerJob?.cancel() + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + + private suspend fun registerLoop(name: String, port: Int) { val deviceId = resolveDeviceId() + _localDeviceId.value = deviceId val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } - if (advertisedService == null) { + + var attempt = 0 + while (advertisedService == null && coroutineContext.isActive) { + attempt++ + // The user may have toggled the setting off during backoff. + if (!settingsRepository.userSettings.value.allowRemoteControl) return try { advertisedService = discoveryService.advertise( name = serviceName, port = port, deviceId = deviceId, + registerTimeoutMs = REGISTER_TIMEOUT_MS, ) - log.i { "Advertising remote control service '$serviceName' on port $port" } + log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } } catch (e: Exception) { - log.w(e) { "Failed to advertise remote control service" } + log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + delay(retryDelayMs(attempt)) } } } private suspend fun stopAdvertising() { + registerJob?.cancel() + registerJob = null if (advertisedService != null) { runCatching { advertisedService?.unregister() } advertisedService = null @@ -101,4 +164,14 @@ class RemoteControlService( settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) return generated } + + private fun retryDelayMs(attempt: Int): Long = when { + attempt >= 6 -> 5 * 60_000L + attempt >= 3 -> 30_000L + else -> 5_000L + } + + companion object { + private const val REGISTER_TIMEOUT_MS = 4_000L + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt new file mode 100644 index 00000000..39b4fdae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -0,0 +1,117 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.remote + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Manages the play destination picker state and remote playback commands. + * Injected into ViewModels to handle playback actions when a remote device is connected. + */ +class RemotePlaybackController : KoinComponent { + private val logger = Logger.withTag("RemotePlaybackController") + private val remoteControlClient: RemoteControlClient by inject() + + private val _showPicker = MutableStateFlow(false) + val showPicker: StateFlow = _showPicker.asStateFlow() + + private var pendingAction: (() -> Unit)? = null + + /** + * Checks if a remote device is connected. + */ + fun isRemoteConnected(): Boolean { + return remoteControlClient.connectionState.value is ConnectionState.Connected + } + + /** + * Wraps a playback action. If a remote device is connected, shows the picker. + * Otherwise, executes the action immediately. + * + * @param action The action to execute if playing locally + */ + fun wrapPlaybackAction(action: () -> Unit) { + if (isRemoteConnected()) { + pendingAction = action + _showPicker.value = true + } else { + action() + } + } + + /** + * Called when the user chooses to play locally. + */ + fun playLocally() { + _showPicker.value = false + pendingAction?.invoke() + pendingAction = null + } + + /** + * Called when the user chooses to play on the remote device. + * Sends a play command to the remote device. + * + * @param source The source identifier (e.g., playlist ID, album ID, track ID) + */ + fun playOnRemote(source: String) { + _showPicker.value = false + pendingAction = null + + CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + remoteControlClient.sendCommand(RemoteControlCommand.Play(source)) + logger.i { "Sent play command for source: $source" } + } catch (e: Exception) { + logger.e(e) { "Failed to send play command" } + } + } + } + + /** + * Called when the user dismisses the picker. + */ + fun dismissPicker() { + _showPicker.value = false + pendingAction = null + } + + /** + * Sends an add-to-queue command to the remote device. + * + * @param source The source identifier (e.g., playlist ID, album ID, track ID) + */ + fun addToQueueOnRemote(source: String) { + CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source)) + logger.i { "Sent add-to-queue command for source: $source" } + } catch (e: Exception) { + logger.e(e) { "Failed to send add-to-queue command" } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt index c61f45c5..e23f9b02 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.modules.settings.SettingsViewModel import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application import io.ktor.server.application.install @@ -63,7 +64,14 @@ class LocalServer( ) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient() + private val httpClient = HttpClient { + // A stalled upstream connection must not wedge the CIO dispatcher thread + // forever. Only the connect phase is bounded — the proxy streams long + // audio bodies, so request/socket timeouts would cut playback short. + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + } + } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serverMutex = Mutex() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index d9b3bf28..b4b4ce9c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -28,9 +28,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,7 +44,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -57,10 +61,19 @@ fun DevicesScreen( val viewModel = koinViewModel() val devices by viewModel.devices.collectAsStateWithLifecycle() val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() + val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() DisposableEffect(Unit) { + // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. + requestLocalNetworkPermission() viewModel.startDiscovery() - onDispose { viewModel.stopDiscovery() } + onDispose { + viewModel.stopDiscovery() + viewModel.disconnect() + } } Scaffold( @@ -69,7 +82,7 @@ fun DevicesScreen( backButton = true, title = { Text("Devices") }, actions = { - if (isDiscovering) { + if (isDiscovering && connectingToDevice == null) { CircularProgressIndicator( modifier = Modifier .size(24.dp) @@ -82,7 +95,9 @@ fun DevicesScreen( contentDescription = "Refresh", modifier = Modifier .size(24.dp) - .clickable { viewModel.startDiscovery() }, + .clickable(enabled = connectingToDevice == null) { + viewModel.startDiscovery() + }, ) } }, @@ -91,53 +106,131 @@ fun DevicesScreen( ) { innerPadding -> val shellBottomInset = LocalAppShellBottomInset.current - if (devices.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = if (isDiscovering) { - "Searching for devices on the network..." - } else { - "No devices found" - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (!isDiscovering) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Error banner + error?.let { errorMessage -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { Text( - text = "Make sure the other device has \"Allow remote control\" enabled in settings.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, ) + OutlinedButton( + onClick = { viewModel.clearError() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Dismiss") + } } } } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 16.dp, - vertical = 8.dp, - ), - ) { - items(devices.values.toList(), key = { it.key }) { device -> - DeviceRow( - device = device, - onClick = { viewModel.connectToDevice(device) }, - ) + + // Connection status + when (val state = connectionState) { + is ConnectionState.Connected -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { + Text( + text = "Connected to ${state.host}:${state.port}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + OutlinedButton( + onClick = { viewModel.disconnect() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Disconnect") + } + } + } } - item { - Box(modifier = Modifier.padding(bottom = shellBottomInset)) + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Connecting...", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + else -> {} + } + + // Device list + if (devices.isEmpty() && connectingToDevice == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (isDiscovering) { + "Searching for devices on the network..." + } else { + "No devices found" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!isDiscovering) { + Text( + text = "Make sure the other device has \"Allow remote control\" enabled in settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + items(devices.values.toList(), key = { it.key }) { device -> + DeviceRow( + device = device, + isConnecting = connectingToDevice?.key == device.key, + onClick = { viewModel.connectToDevice(device) }, + ) + } + item { + Box(modifier = Modifier.padding(bottom = shellBottomInset)) + } } } } @@ -147,35 +240,56 @@ fun DevicesScreen( @Composable private fun DeviceRow( device: DiscoveredDevice, + isConnecting: Boolean, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clickable(onClick = onClick, enabled = !isConnecting) .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } Column(modifier = Modifier.weight(1f)) { Text( - text = device.name, + text = device.name.ifBlank { "Unknown Device" }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - text = "${device.host}:${device.port}", + text = if (device.host.isNotBlank() && device.port > 0) { + "${device.host}:${device.port}" + } else { + "Resolving..." + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (device.deviceId.isNotBlank()) { + Text( + text = "ID: ${device.deviceId.take(8)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt index 5c8809f6..435feaaf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -20,10 +20,15 @@ package dev.krtirtho.spotube.modules.devices import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger -import com.appstractive.dnssd.NetService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discovery.DiscoveredDevice import dev.krtirtho.spotube.core.discovery.DiscoveryState +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlService +import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,9 +38,14 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject -class DevicesViewModel : ViewModel(), KoinComponent { +class DevicesViewModel( + private val navigationCommands: NavigationCommands, +) : ViewModel(), KoinComponent { private val logger = Logger.withTag("DevicesViewModel") private val discoveryService: DeviceDiscoveryService by inject() + private val remoteControlClient: RemoteControlClient by inject() + private val remoteControlService: RemoteControlService by inject() + private val settingsProvider: SettingsProvider by inject() private val _devices = MutableStateFlow>(emptyMap()) val devices: StateFlow> = _devices.asStateFlow() @@ -43,30 +53,109 @@ class DevicesViewModel : ViewModel(), KoinComponent { private val _isDiscovering = MutableStateFlow(false) val isDiscovering: StateFlow = _isDiscovering.asStateFlow() + private val _connectingToDevice = MutableStateFlow(null) + val connectingToDevice: StateFlow = _connectingToDevice.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + private var discoveryJob: Job? = null - private var advertisedService: NetService? = null + + init { + // Observe connection state from the client + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + if (state is ConnectionState.Error) { + _error.value = state.message + _connectingToDevice.value = null + } else if (state is ConnectionState.Disconnected) { + _connectingToDevice.value = null + } else if (state is ConnectionState.Connected) { + // Navigate to remote control screen after successful connection + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + } + // Whenever the local device id is resolved, drop any of our own + // advertisements that may have been picked up before we knew our id. + viewModelScope.launch { + remoteControlService.localDeviceId.collect { id -> + if (id.isNotBlank()) { + removeSelf() + } + } + } + } fun startDiscovery() { if (discoveryJob?.isActive == true) return _isDiscovering.value = true + _error.value = null + logger.i { "Starting device discovery" } + // Advertising may have failed before the local-network permission was + // granted; give it another chance now that discovery is being used. + remoteControlService.retryAdvertising() discoveryJob = viewModelScope.launch { - discoveryService.discover().collect { event -> - when (event) { - is DiscoveryState.Discovered -> { - event.resolve() - _devices.update { it + (event.device.key to event.device.copy()) } - } - is DiscoveryState.Resolved -> { - _devices.update { it + (event.device.key to event.device) } - } - is DiscoveryState.Removed -> { - _devices.update { it - event.device.key } + try { + discoveryService.discover().collect { event -> + logger.d { "Discovery event: $event" } + when (event) { + is DiscoveryState.Discovered -> { + event.resolve() + if (!isSelf(event.device)) { + _devices.update { it + (event.device.key to event.device.copy()) } + } + } + is DiscoveryState.Resolved -> { + if (isSelf(event.device)) { + // Resolved now carries our deviceId in TXT; drop self. + _devices.update { it - event.device.key } + } else { + _devices.update { it + (event.device.key to event.device) } + } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } } } + } catch (e: Exception) { + logger.e(e) { "Discovery failed" } + _error.value = "Discovery failed: ${e.message}" + _isDiscovering.value = false } } } + /** + * True when [device] is this device's own advertisement. On the initial + * `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is + * empty), so we match by the name we advertise; once resolved we also have + * the authoritative deviceId. + */ + private fun isSelf(device: DiscoveredDevice): Boolean { + val localId = remoteControlService.localDeviceId.value.ifBlank { + settingsProvider.settingsState.value?.remoteControlDeviceId ?: "" + } + if (localId.isNotBlank() && device.deviceId == localId) return true + // Match by the deterministic advertised name as a fallback for the + // pre-resolution event where deviceId isn't available yet. + return device.name.isNotBlank() && device.name == remoteControlService.advertisedName() + } + + private fun removeSelf() { + val localId = remoteControlService.localDeviceId.value + if (localId.isBlank()) return + _devices.update { map -> + map.filterNot { (_, device) -> device.deviceId == localId } + } + } + fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null @@ -74,6 +163,51 @@ class DevicesViewModel : ViewModel(), KoinComponent { } fun connectToDevice(device: DiscoveredDevice) { + if (_connectingToDevice.value != null) { + logger.w { "Already connecting to a device" } + return + } + if (isSelf(device)) { + logger.w { "Refusing to connect to self: ${device.name}" } + return + } + + _connectingToDevice.value = device + _error.value = null logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + + viewModelScope.launch { + try { + val settings = settingsProvider.settingsState.value + val deviceId = settings?.remoteControlDeviceId ?: "" + val deviceName = settings?.remoteControlDeviceName?.ifBlank { "Spotube Controller" } + ?: "Spotube Controller" + + remoteControlClient.connect( + host = device.host, + port = device.port, + deviceId = deviceId, + deviceName = deviceName, + ) + + // Clear connecting state after connection attempt + // The connectionState flow will show the actual connection status + _connectingToDevice.value = null + } catch (e: Exception) { + logger.e(e) { "Failed to connect to device" } + _error.value = "Failed to connect: ${e.message}" + _connectingToDevice.value = null + } + } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } + + fun clearError() { + _error.value = null } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt new file mode 100644 index 00000000..3c412260 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -0,0 +1,88 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import org.koin.compose.koinInject + +/** + * Dialog shown when a remote device is connected and the user tries to play/add to queue. + * Allows the user to choose between playing on the local device or the remote device. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onPlayOnRemote: () -> Unit, +) { + val remoteControlClient = koinInject() + val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + + if (!visible) return + + val remoteDeviceName = when (val state = connectionState) { + is ConnectionState.Connected -> "Remote Device (${state.host})" + else -> "Remote Device" + } + + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Play Where?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Choose where to play this content:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + TextButton(onClick = onPlayLocally) { + Text("This Device") + } + TextButton(onClick = onPlayOnRemote) { + Text(remoteDeviceName) + } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt new file mode 100644 index 00000000..e5f1e7f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -0,0 +1,375 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious +import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle +import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh +import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RemoteControlScreen( + onDisconnect: () -> Unit, +) { + val viewModel = koinViewModel() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + GhostIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + } + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect", + ) + } + } + ) + } + ) { padding -> + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(padding) + ) + } + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } + } + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } + } + } + } +} + +@Composable +private fun RemoteControlContent( + playerState: RemotePlayerState, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onSetVolume: (Float) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoopMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Album art + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + ) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = playerState.currentTrackTitle ?: "Unknown Track", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = playerState.currentTrackArtists ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (playerState.currentTrackAlbum != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playerState.currentTrackAlbum!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Seek bar + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Slider( + value = playerState.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(playerState.positionMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(playerState.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // Shuffle + IconButton( + onClick = onToggleShuffle, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Skip previous + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = "Previous", + modifier = Modifier.size(32.dp), + ) + } + + // Play/Pause + IconButton( + onClick = onTogglePlayPause, + modifier = Modifier + .size(72.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(40.dp), + ) + } + + // Skip next + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = "Next", + modifier = Modifier.size(32.dp), + ) + } + + // Loop mode + IconButton( + onClick = onCycleLoopMode, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Volume control + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxVolumeHigh, + contentDescription = "Volume", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + + Slider( + value = playerState.volume, + onValueChange = onSetVolume, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +private fun formatDuration(ms: Long): String { + val duration = ms.milliseconds + val minutes = duration.inWholeMinutes + val seconds = duration.inWholeSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt new file mode 100644 index 00000000..65983a86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -0,0 +1,170 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlCommand +import dev.krtirtho.spotube.core.remote.RemoteControlEvent +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +data class RemotePlayerState( + val isPlaying: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val volume: Float = 1.0f, + val shuffleEnabled: Boolean = false, + val loopMode: String = "none", + val currentTrackId: String? = null, + val currentTrackTitle: String? = null, + val currentTrackArtists: String? = null, + val currentTrackAlbum: String? = null, + val currentTrackCoverUrl: String? = null, +) + +class RemoteControlViewModel : ViewModel(), KoinComponent { + private val logger = Logger.withTag("RemoteControlViewModel") + private val remoteControlClient: RemoteControlClient by inject() + + private val _playerState = MutableStateFlow(RemotePlayerState()) + val playerState: StateFlow = _playerState.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private var stateUpdateJob: Job? = null + + init { + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + } + } + + viewModelScope.launch { + remoteControlClient.stateUpdates.collect { event -> + handleStateUpdate(event) + } + } + } + + private fun handleStateUpdate(event: RemoteControlEvent) { + when (event) { + is RemoteControlEvent.Connected -> { + // Connection already handled in RemoteControlClient + logger.d { "Connection confirmed" } + } + is RemoteControlEvent.WaitingForPermission -> { + // Waiting for permission - no action needed + logger.d { "Waiting for permission: ${event.message}" } + } + is RemoteControlEvent.PlayerState -> { + _playerState.update { + it.copy( + isPlaying = event.isPlaying, + positionMs = event.positionMs, + durationMs = event.durationMs, + volume = event.volume, + shuffleEnabled = event.shuffleEnabled, + loopMode = event.loopMode, + currentTrackId = event.currentTrackId, + currentTrackTitle = event.currentTrackTitle, + currentTrackArtists = event.currentTrackArtists, + currentTrackAlbum = event.currentTrackAlbum, + currentTrackCoverUrl = event.currentTrackCoverUrl, + ) + } + } + is RemoteControlEvent.QueueUpdated -> { + // TODO: Handle queue updates if needed + logger.d { "Queue updated: ${event.entries.size} entries" } + } + is RemoteControlEvent.Ack -> { + logger.d { "Command acknowledged: ${event.commandId}" } + } + is RemoteControlEvent.Error -> { + logger.e { "Remote error: ${event.message}" } + } + } + } + + fun togglePlayPause() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) + } + } + + fun skipNext() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipNext) + } + } + + fun skipPrevious() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious) + } + } + + fun seek(positionMs: Long) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs)) + } + } + + fun setVolume(volume: Float) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + } + } + + fun toggleShuffle() { + viewModelScope.launch { + val newState = !_playerState.value.shuffleEnabled + remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState)) + } + } + + fun cycleLoopMode() { + viewModelScope.launch { + val currentMode = _playerState.value.loopMode + val newMode = when (currentMode) { + "none" -> "one" + "one" -> "all" + else -> "none" + } + remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode)) + } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..dfbc15e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -38,6 +38,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView +import dev.krtirtho.spotube.modules.devices.PlayDestinationPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet @@ -59,6 +60,7 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle() var showEditPlaylist by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) } @@ -169,6 +171,13 @@ fun PlaylistScreen( viewModel.refresh() }, ) + + PlayDestinationPicker( + visible = showPlayDestinationPicker, + onDismiss = viewModel::dismissPlayPicker, + onPlayLocally = viewModel::playLocally, + onPlayOnRemote = viewModel::playOnRemote, + ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 7cd51cbb..e0e3a054 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -98,6 +99,7 @@ class PlaylistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -115,6 +117,8 @@ class PlaylistViewModel( private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + val showPlayDestinationPicker = remotePlaybackController.showPicker + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) private val _showAddToPlaylistPicker = MutableStateFlow(false) val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() @@ -223,15 +227,35 @@ class PlaylistViewModel( } fun playPlaylist() { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + remotePlaybackController.wrapPlaybackAction { + viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + } } fun addPlaylistToQueue() { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + if (remotePlaybackController.isRemoteConnected()) { + remotePlaybackController.addToQueueOnRemote(playlistId) + } else { + viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + } } fun playPlaylistFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + remotePlaybackController.wrapPlaybackAction { + viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + } + } + + fun playLocally() { + remotePlaybackController.playLocally() + } + + fun playOnRemote() { + remotePlaybackController.playOnRemote(playlistId) + } + + fun dismissPlayPicker() { + remotePlaybackController.dismissPicker() } fun refresh() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index e420009a..7601f014 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* @@ -62,6 +63,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { platformType == PlatformType.MacOS val shellBottomInset = LocalAppShellBottomInset.current + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val contentPadding = remember(shellBottomInset) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } @@ -106,6 +108,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { settings = settingsState!!, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, ) if (settingsState != null) cacheSection( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 658e6119..52d0a190 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -50,6 +50,7 @@ internal fun LazyListScope.playbackSection( settings: UserSettings, settingsViewModel: SettingsViewModel, navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, ) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( @@ -151,6 +152,11 @@ internal fun LazyListScope.playbackSection( settingsViewModel.updateSettings { copy(allowRemoteControl = enabled) } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } } ) }, diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt new file mode 100644 index 00000000..4b02635f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt @@ -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 . + */ + +package dev.krtirtho.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on iOS. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt new file mode 100644 index 00000000..68522b82 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt @@ -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 . + */ + +package dev.krtirtho.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on the JVM. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file From c5d35bfc935822eceb08cb82f55716380b49141c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 09:07:08 +0600 Subject: [PATCH 006/132] fix(remote-control): update connection handling and improve logging for remote control events --- .../spotube/core/remote/RemoteControlClient.kt | 4 ++++ .../spotube/core/remote/RemoteControlHandler.kt | 17 ++++++++++++----- .../spotube/modules/devices/DevicesScreen.kt | 3 ++- .../modules/devices/RemoteControlScreen.kt | 13 +++++++++---- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index e6d268f9..b88fca90 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -101,13 +101,16 @@ class RemoteControlClient { private suspend fun receiveLoop(host: String, port: Int) { val currentSession = session ?: return + logger.d { "Starting receive loop for $host:$port" } try { for (frame in currentSession.incoming) { when (frame) { is Frame.Text -> { val text = frame.readText() + logger.d { "Received frame: $text" } try { val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + logger.d { "Parsed event: $event" } when (event) { is RemoteControlEvent.Connected -> { logger.i { "Connection authorized by server" } @@ -136,6 +139,7 @@ class RemoteControlClient { else -> {} } } + logger.d { "Receive loop exited normally" } } catch (e: Exception) { logger.e(e) { "Error in receive loop" } _connectionState.value = ConnectionState.Error(e.message ?: "Connection lost") diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index f192855a..3c709a95 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -74,7 +74,7 @@ class RemoteControlHandler( val waitingMessage = RemoteControlEvent.WaitingForPermission( "Waiting for permission from $deviceName..." ) - session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage))) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage))) val request = ConnectionRequest( deviceId = deviceId ?: "unknown", @@ -99,14 +99,18 @@ class RemoteControlHandler( } // Send connected message - session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected))) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected))) logger.i { "Remote control connection established from $deviceName ($deviceId)" } + // Broadcast initial player state so the controller shows current track info + broadcastState(session) + try { handleControlLoop(session) } catch (e: Exception) { logger.w(e) { "Error in remote control session" } } finally { + logger.d { "Closing session in finally block" } session.close() } } @@ -129,9 +133,11 @@ class RemoteControlHandler( } private suspend fun handleControlLoop(session: WebSocketServerSession) { + logger.d { "Starting control loop for session" } for (frame in session.incoming) { if (frame is Frame.Text) { val text = frame.readText() + logger.d { "Received command: $text" } try { val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) handleCommand(session, envelope) @@ -141,6 +147,7 @@ class RemoteControlHandler( } } } + logger.d { "Control loop exited normally" } } private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { @@ -197,12 +204,12 @@ class RemoteControlHandler( } private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { - val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId)) + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId)) session.send(Frame.Text(text)) } private suspend fun sendError(session: WebSocketServerSession, message: String) { - val text = json.encodeToString(RemoteControlEvent.Error.serializer(), RemoteControlEvent.Error(message)) + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message)) session.send(Frame.Text(text)) } @@ -221,7 +228,7 @@ class RemoteControlHandler( currentTrackAlbum = current?.albumOrNull(), currentTrackCoverUrl = current?.coverUrlOrNull(), ) - val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state) + val text = json.encodeToString(RemoteControlEvent.serializer(), state) session.send(Frame.Text(text)) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index b4b4ce9c..95366a4a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -72,7 +72,8 @@ fun DevicesScreen( viewModel.startDiscovery() onDispose { viewModel.stopDiscovery() - viewModel.disconnect() + // Don't disconnect here - the connection should persist when navigating to RemoteControlScreen + // The RemoteControlViewModel will manage the connection lifecycle } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index e5f1e7f8..5880ee1f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -57,6 +57,7 @@ import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxNext @@ -77,6 +78,7 @@ fun RemoteControlScreen( val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current Scaffold( topBar = { @@ -110,14 +112,15 @@ fun RemoteControlScreen( onSetVolume = viewModel::setVolume, onToggleShuffle = viewModel::toggleShuffle, onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(padding) + modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) ) } is ConnectionState.Connecting -> { Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { Text("Connecting...") @@ -127,7 +130,8 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { Text("Disconnected") @@ -137,7 +141,8 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { Text("Connection error: ${(connectionState as ConnectionState.Error).message}") From 1c169d007a081b527e71a1d5a13b1833df6c779c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 09:24:15 +0600 Subject: [PATCH 007/132] feat(remote-control): enhance queue management and player state synchronization --- .../core/remote/RemoteControlClient.kt | 22 +- .../core/remote/RemoteControlHandler.kt | 110 +++++++- .../core/remote/RemoteControlProtocol.kt | 4 + .../modules/devices/RemoteControlScreen.kt | 236 ++++++++++++++++-- .../modules/devices/RemoteControlViewModel.kt | 103 +++++--- 5 files changed, 406 insertions(+), 69 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index b88fca90..8f2adeea 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -33,11 +33,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -69,8 +66,11 @@ class RemoteControlClient { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private val _stateUpdates = MutableSharedFlow(extraBufferCapacity = 32) - val stateUpdates: SharedFlow = _stateUpdates.asSharedFlow() + private val _latestPlayerState = MutableStateFlow(null) + val latestPlayerState: StateFlow = _latestPlayerState.asStateFlow() + + private val _latestQueue = MutableStateFlow(null) + val latestQueue: StateFlow = _latestQueue.asStateFlow() suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { if (_connectionState.value is ConnectionState.Connected) { @@ -121,9 +121,17 @@ class RemoteControlClient { // Keep showing connecting state } else -> { - // Only emit state updates after connection is established + // Only store state updates after connection is established if (_connectionState.value is ConnectionState.Connected) { - _stateUpdates.emit(event) + when (event) { + is RemoteControlEvent.PlayerState -> { + _latestPlayerState.value = event + } + is RemoteControlEvent.QueueUpdated -> { + _latestQueue.value = event + } + else -> {} + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 3c709a95..89be1235 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -30,8 +30,14 @@ import io.ktor.websocket.Frame import io.ktor.websocket.close import io.ktor.websocket.readText import kotlin.coroutines.resume +import kotlin.time.TimeSource +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -104,9 +110,20 @@ class RemoteControlHandler( // Broadcast initial player state so the controller shows current track info broadcastState(session) + broadcastQueue(session) try { - handleControlLoop(session) + // Periodically push player state so the controller's progress bar + // stays in sync even when no commands are being sent. + coroutineScope { + launch { + while (isActive) { + delay(1_000) + broadcastState(session) + } + } + handleControlLoop(session) + } } catch (e: Exception) { logger.w(e) { "Error in remote control session" } } finally { @@ -159,10 +176,13 @@ class RemoteControlHandler( audioPlayer.pause() } is RemoteControlCommand.TogglePlayPause -> { - if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) { + val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING + if (isPlaying) { audioPlayer.pause() + waitForPlaybackState(expectPlaying = false) } else { audioPlayer.play() + waitForPlaybackState(expectPlaying = true) } } is RemoteControlCommand.Seek -> { @@ -195,12 +215,27 @@ class RemoteControlHandler( is RemoteControlCommand.AddToQueue -> { logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } } + is RemoteControlCommand.PlayIndex -> { + audioPlayerQueue.jumpTo(command.index) + } is RemoteControlCommand.RemoveFromQueue -> { - audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl) + val queue = audioPlayerQueue.queueFlow.value + val entry = queue.firstOrNull { candidate -> + when (candidate) { + is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl + is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl + } + } + if (entry != null) { + audioPlayerQueue.removeFromQueue(entry) + } else { + logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" } + } } } sendAck(session, envelope.commandId) broadcastState(session) + broadcastQueue(session) } private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { @@ -213,6 +248,20 @@ class RemoteControlHandler( session.send(Frame.Text(text)) } + /** + * Player state changes are applied asynchronously (e.g. ExoPlayer listener + * callbacks posted to the main looper), so after play/pause we poll until + * [playerStateFlow] reflects the expected state before broadcasting it back + * to the controller. Otherwise the client would see a stale (inverted) icon. + */ + private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) { + val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds + while (timeoutAt.hasNotPassedNow()) { + if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return + delay(25) + } + } + private suspend fun broadcastState(session: WebSocketServerSession) { val current = audioPlayerQueue.currentQueueEntryFlow.value val state = RemoteControlEvent.PlayerState( @@ -232,6 +281,58 @@ class RemoteControlHandler( session.send(Frame.Text(text)) } + private suspend fun broadcastQueue(session: WebSocketServerSession) { + val queue = audioPlayerQueue.queueFlow.value + val current = audioPlayerQueue.currentQueueEntryFlow.value + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesCurrent(current) } + } else { + -1 + } + val event = RemoteControlEvent.QueueUpdated( + entries = queue.map { it.toRemoteQueueEntry() }, + currentIndex = currentIndex, + ) + val text = json.encodeToString(RemoteControlEvent.serializer(), event) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> { + this.track.id == current.track.id + } + + this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> { + this.url == current.url && this.name == current.name + } + + else -> false + } + } + + private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) { + is QueueEntry.StreamingTrack -> RemoteQueueEntry( + mediaUrl = track.id, + trackId = track.id, + title = track.title, + artists = track.artists.joinToString(", ") { artist -> artist.name }, + album = track.album?.title, + coverUrl = coverUrlOrNull(), + durationMs = track.durationMs, + ) + + is QueueEntry.LocalTrack -> RemoteQueueEntry( + mediaUrl = url, + trackId = url, + title = name, + artists = artists.joinToString(", "), + album = album, + coverUrl = null, + durationMs = duration, + ) + } + private fun QueueEntry.mediaKey(): String = when (this) { is QueueEntry.StreamingTrack -> track.id is QueueEntry.LocalTrack -> url @@ -253,7 +354,8 @@ class RemoteControlHandler( } private fun QueueEntry.coverUrlOrNull(): String? = when (this) { - is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url + is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url is QueueEntry.LocalTrack -> null } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index 5e63d305..f2739792 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -62,6 +62,10 @@ sealed class RemoteControlCommand { @SerialName("addToQueue") data class AddToQueue(val source: String) : RemoteControlCommand() + @Serializable + @SerialName("playIndex") + data class PlayIndex(val index: Int) : RemoteControlCommand() + @Serializable @SerialName("removeFromQueue") data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index 5880ee1f..ed03f6f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -29,10 +30,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -40,11 +45,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -53,13 +59,22 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry +import dev.krtirtho.spotube.core.ui.base.BaseUITheme import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.ListRowTile +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -78,6 +93,8 @@ fun RemoteControlScreen( val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val queueState by viewModel.queueState.collectAsStateWithLifecycle() + val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current Scaffold( @@ -86,6 +103,19 @@ fun RemoteControlScreen( title = { Text("Remote Control") }, backButton = true, actions = { + GhostIconButton( + onClick = viewModel::toggleQueueVisibility, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } GhostIconButton( onClick = { viewModel.disconnect() @@ -150,6 +180,18 @@ fun RemoteControlScreen( } } } + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { viewModel.toggleQueueVisibility() }, + modifier = Modifier.fillMaxSize(), + ) { + RemoteQueueSection( + queueState = queueState, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem, + ) + } } @Composable @@ -179,13 +221,28 @@ private fun RemoteControlContent( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = "Album cover", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + if (playerState.currentTrackCoverUrl != null) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } + } } Spacer(modifier = Modifier.height(32.dp)) @@ -295,14 +352,16 @@ private fun RemoteControlContent( } // Play/Pause - IconButton( + val baseTheme = LocalBaseUITheme.current + val circlePrimaryIconTheme = remember(baseTheme) { + baseTheme.iconButtons.primary.copy( + shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) + ) + } + PrimaryIconButton( onClick = onTogglePlayPause, - modifier = Modifier - .size(72.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), + modifier = Modifier.size(72.dp), + theme = circlePrimaryIconTheme, ) { Icon( imageVector = if (playerState.isPlaying) { @@ -311,7 +370,6 @@ private fun RemoteControlContent( Iconsax.IconsaxPlay }, contentDescription = if (playerState.isPlaying) "Pause" else "Play", - tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(40.dp), ) } @@ -372,9 +430,153 @@ private fun RemoteControlContent( } } +@Composable +private fun RemoteQueueSection( + queueState: RemoteQueueState, + onPlayQueueItem: (Int) -> Unit, + onRemoveQueueItem: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + ) + + if (queueState.entries.isEmpty()) { + Text( + text = "No queue entries", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed( + items = queueState.entries, + key = { index, entry -> "${entry.mediaUrl}@$index" }, + ) { index, entry -> + RemoteQueueItemRow( + entry = entry, + index = index, + isCurrent = index == queueState.currentIndex, + onPlayClick = { onPlayQueueItem(index) }, + onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }, + ) + } + } + } + } +} + +@Composable +private fun RemoteQueueItemRow( + entry: RemoteQueueEntry, + index: Int, + isCurrent: Boolean, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + ListRowTile( + onClick = onPlayClick, + selected = isCurrent, + modifier = Modifier, + leading = { + Box( + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (entry.coverUrl != null) { + AsyncImage( + model = entry.coverUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + title = { + Text( + text = entry.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isCurrent) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + subtitle = { + Text( + text = entry.artists, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailing = { + Text( + text = formatDuration(entry.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box { + GhostIconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Remove from queue") }, + onClick = { + onRemoveClick() + showMenu = false + }, + leadingIcon = { + Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) + }, + ) + } + } + } + ) +} + private fun formatDuration(ms: Long): String { val duration = ms.milliseconds val minutes = duration.inWholeMinutes val seconds = duration.inWholeSeconds % 60 return "$minutes:${seconds.toString().padStart(2, '0')}" -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt index 65983a86..9e329f26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -24,7 +24,7 @@ import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlCommand import dev.krtirtho.spotube.core.remote.RemoteControlEvent -import kotlinx.coroutines.Job +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,6 +47,11 @@ data class RemotePlayerState( val currentTrackCoverUrl: String? = null, ) +data class RemoteQueueState( + val entries: List = emptyList(), + val currentIndex: Int = -1, +) + class RemoteControlViewModel : ViewModel(), KoinComponent { private val logger = Logger.withTag("RemoteControlViewModel") private val remoteControlClient: RemoteControlClient by inject() @@ -57,7 +62,11 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private var stateUpdateJob: Job? = null + private val _queueState = MutableStateFlow(RemoteQueueState()) + val queueState: StateFlow = _queueState.asStateFlow() + + private val _isQueueVisible = MutableStateFlow(false) + val isQueueVisible: StateFlow = _isQueueVisible.asStateFlow() init { viewModelScope.launch { @@ -67,52 +76,47 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { } viewModelScope.launch { - remoteControlClient.stateUpdates.collect { event -> - handleStateUpdate(event) + remoteControlClient.latestPlayerState.collect { event -> + if (event != null) { + handlePlayerState(event) + } + } + } + + viewModelScope.launch { + remoteControlClient.latestQueue.collect { event -> + if (event != null) { + handleQueueUpdated(event) + } } } } - private fun handleStateUpdate(event: RemoteControlEvent) { - when (event) { - is RemoteControlEvent.Connected -> { - // Connection already handled in RemoteControlClient - logger.d { "Connection confirmed" } - } - is RemoteControlEvent.WaitingForPermission -> { - // Waiting for permission - no action needed - logger.d { "Waiting for permission: ${event.message}" } - } - is RemoteControlEvent.PlayerState -> { - _playerState.update { - it.copy( - isPlaying = event.isPlaying, - positionMs = event.positionMs, - durationMs = event.durationMs, - volume = event.volume, - shuffleEnabled = event.shuffleEnabled, - loopMode = event.loopMode, - currentTrackId = event.currentTrackId, - currentTrackTitle = event.currentTrackTitle, - currentTrackArtists = event.currentTrackArtists, - currentTrackAlbum = event.currentTrackAlbum, - currentTrackCoverUrl = event.currentTrackCoverUrl, - ) - } - } - is RemoteControlEvent.QueueUpdated -> { - // TODO: Handle queue updates if needed - logger.d { "Queue updated: ${event.entries.size} entries" } - } - is RemoteControlEvent.Ack -> { - logger.d { "Command acknowledged: ${event.commandId}" } - } - is RemoteControlEvent.Error -> { - logger.e { "Remote error: ${event.message}" } - } + private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { + _playerState.update { + it.copy( + isPlaying = event.isPlaying, + positionMs = event.positionMs, + durationMs = event.durationMs, + volume = event.volume, + shuffleEnabled = event.shuffleEnabled, + loopMode = event.loopMode, + currentTrackId = event.currentTrackId, + currentTrackTitle = event.currentTrackTitle, + currentTrackArtists = event.currentTrackArtists, + currentTrackAlbum = event.currentTrackAlbum, + currentTrackCoverUrl = event.currentTrackCoverUrl, + ) } } + private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { + _queueState.value = RemoteQueueState( + entries = event.entries, + currentIndex = event.currentIndex, + ) + } + fun togglePlayPause() { viewModelScope.launch { remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) @@ -162,6 +166,23 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { } } + fun playQueueItem(index: Int) { + if (index < 0) return + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index)) + } + } + + fun removeQueueItem(mediaUrl: String) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl)) + } + } + + fun toggleQueueVisibility() { + _isQueueVisible.update { !it } + } + fun disconnect() { viewModelScope.launch { remoteControlClient.disconnect() From 7bf17afc427fdadc83bc8edbed0f6672002b47f0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 12:02:32 +0600 Subject: [PATCH 008/132] feat(remote-control): enhance remote playback functionality with new commands and UI integration --- .../dev/krtirtho/spotube/core/di/Modules.kt | 8 +- .../core/playback/CollectionPlaybackHelper.kt | 51 ++ .../core/remote/RemoteControlHandler.kt | 95 ++- .../core/remote/RemoteControlProtocol.kt | 25 + .../core/remote/RemotePlaybackController.kt | 343 ++++++++-- .../spotube/core/ui/component/AlbumCard.kt | 20 +- .../spotube/core/ui/component/PlaylistCard.kt | 17 +- .../spotube/modules/album/AlbumViewModel.kt | 64 +- .../spotube/modules/artist/ArtistViewModel.kt | 103 ++- .../modules/devices/PlayDestinationPicker.kt | 98 ++- .../modules/devices/RemoteControlScreen.kt | 597 ++++++++++-------- .../modules/playlist/PlaylistScreen.kt | 9 - .../modules/playlist/PlaylistViewModel.kt | 84 +-- .../saved_tracks/SavedTracksViewModel.kt | 54 +- .../spotube/modules/search/SearchScreen.kt | 42 +- .../spotube/modules/shell/AppShell.kt | 2 + 16 files changed, 1012 insertions(+), 600 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index a2b6fe98..1b3d4f3c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -158,6 +158,7 @@ val sharedModules = module { libraryRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -173,6 +174,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -196,6 +198,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -208,6 +211,7 @@ val sharedModules = module { albumRepository = get(), playlistRepository = get(), savedTracksRepository = get(), + artistRepository = get(), audioPlayerQueue = get(), blacklistRepository = get(), ) @@ -219,13 +223,13 @@ val sharedModules = module { singleOf(::LocalServer) withOptions { createdAtStart() } - single { RemoteControlHandler(get(), get(), get()) } + single { RemoteControlHandler(get(), get(), get(), get()) } single { RemoteControlClient() } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } - single { RemotePlaybackController() } + single { RemotePlaybackController(get(), get(), get(), get()) } single { JamSessionService(get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 81594eda..7441309c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.album.AlbumRepository +import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository @@ -30,6 +31,7 @@ class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, private val playlistRepository: PlaylistRepository, private val savedTracksRepository: SavedTracksRepository, + private val artistRepository: ArtistRepository, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, ) { @@ -56,6 +58,13 @@ class CollectionPlaybackHelper( } } + suspend fun playAlbumNext(albumId: String) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) { val entries = fetchAllAlbumTracks(albumId) if (entries.isEmpty()) return @@ -104,6 +113,13 @@ class CollectionPlaybackHelper( } } + suspend fun playPlaylistNext(playlistId: String) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) { val entries = fetchAllPlaylistTracks(playlistId) if (entries.isEmpty()) return @@ -142,6 +158,32 @@ class CollectionPlaybackHelper( } } + suspend fun playArtistTopTracks(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + } + + suspend fun addArtistTopTracksToQueue(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue(entries) + } + } + + suspend fun playArtistTopTracksNext(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun addSavedTracksToQueue() { val entries = fetchAllSavedTracks() if (entries.isNotEmpty()) { @@ -243,6 +285,15 @@ class CollectionPlaybackHelper( } } + private suspend fun fetchArtistTopTracks(artistId: String): List { + val tracks = artistRepository.topTracks(artistId).orEmpty() + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return tracks + .filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + } + private fun isTrackBlacklisted( track: MetadataTrack, blacklistedTrackIds: Set, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 89be1235..602fc9f1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.settings.SettingsRepository import io.ktor.server.websocket.WebSocketServerSession import io.ktor.websocket.CloseReason @@ -47,6 +48,7 @@ class RemoteControlHandler( private val settingsRepository: SettingsRepository, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, + private val collectionPlaybackHelper: CollectionPlaybackHelper, ) : KoinComponent { val logger by injectLogger() @@ -170,7 +172,7 @@ class RemoteControlHandler( private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { when (val command = envelope.command) { is RemoteControlCommand.Play -> { - logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" } + handleCollectionSource(command.source, RemoteCollectionAction.Play) } is RemoteControlCommand.Pause -> { audioPlayer.pause() @@ -213,7 +215,36 @@ class RemoteControlHandler( audioPlayer.loop(loopState) } is RemoteControlCommand.AddToQueue -> { - logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } + handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue) + } + is RemoteControlCommand.PlayNext -> { + handleCollectionSource(command.source, RemoteCollectionAction.PlayNext) + } + is RemoteControlCommand.PlayTrack -> { + audioPlayerQueue.load( + entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + is RemoteControlCommand.AddTrackToQueue -> { + audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) + } + is RemoteControlCommand.PlayTrackNext -> { + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = ""))) + } + is RemoteControlCommand.AddTracksToQueue -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllToQueue(entries) + } + is RemoteControlCommand.PlayTracksNext -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllAfterCurrent(entries) } is RemoteControlCommand.PlayIndex -> { audioPlayerQueue.jumpTo(command.index) @@ -262,6 +293,55 @@ class RemoteControlHandler( } } + /** + * Resolves a `spotube://` collection source URI (playlist/album/artist top + * tracks/saved tracks) and applies the requested action on the remote queue. + */ + private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) { + logger.d { "Remote collection $action for source: $source" } + when { + source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { + val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) + } + } + + source.startsWith(COLLECTION_ALBUM_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) + } + } + + source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id) + } + } + + source == COLLECTION_SAVED_TRACKS -> { + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks() + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + RemoteCollectionAction.PlayNext -> { + // Saved tracks "play next" is not supported; add to queue instead + collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + else -> logger.w { "Unknown remote collection source: $source" } + } + } + private suspend fun broadcastState(session: WebSocketServerSession) { val current = audioPlayerQueue.currentQueueEntryFlow.value val state = RemoteControlEvent.PlayerState( @@ -366,6 +446,17 @@ data class CommandEnvelope( val command: RemoteControlCommand, ) +private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" +private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" +private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/" +private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks" + +enum class RemoteCollectionAction { + Play, + AddToQueue, + PlayNext, +} + data class ConnectionRequest( val deviceId: String, val deviceName: String, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index f2739792..fc51834e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.remote +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -62,6 +63,30 @@ sealed class RemoteControlCommand { @SerialName("addToQueue") data class AddToQueue(val source: String) : RemoteControlCommand() + @Serializable + @SerialName("playNext") + data class PlayNext(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playTrack") + data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTrackToQueue") + data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("playTrackNext") + data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTracksToQueue") + data class AddTracksToQueue(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playTracksNext") + data class PlayTracksNext(val tracks: List) : RemoteControlCommand() + @Serializable @SerialName("playIndex") data class PlayIndex(val index: Int) : RemoteControlCommand() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 39b4fdae..a0ba5f5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -18,100 +18,307 @@ package dev.krtirtho.spotube.core.remote import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.IO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent -import org.koin.core.component.inject + +enum class PlaybackDestinationAction { + Play, + AddToQueue, + PlayNext, +} + +enum class RemoteCollectionType { + Playlist, + Album, + ArtistTopTracks, + SavedTracks, +} /** - * Manages the play destination picker state and remote playback commands. - * Injected into ViewModels to handle playback actions when a remote device is connected. + * A playback request awaiting a destination choice (local device vs a connected + * remote device). [title] is the content label shown in the picker dialog. */ -class RemotePlaybackController : KoinComponent { +sealed interface PlaybackDestinationRequest { + val title: String + val action: PlaybackDestinationAction + + data class Collection( + override val title: String, + override val action: PlaybackDestinationAction, + val type: RemoteCollectionType, + val id: String, + val startTrack: MetadataTrack? = null, + ) : PlaybackDestinationRequest + + data class Track( + override val title: String, + override val action: PlaybackDestinationAction, + val track: MetadataTrack, + ) : PlaybackDestinationRequest + + data class Tracks( + override val title: String, + override val action: PlaybackDestinationAction, + val tracks: List, + ) : PlaybackDestinationRequest +} + +/** + * Routes playback actions (play / add to queue / play next) to either the local + * device or a connected remote device. When a remote device is connected the + * user is shown a destination picker; otherwise the action runs locally. + */ +class RemotePlaybackController( + private val remoteControlClient: RemoteControlClient, + private val collectionPlaybackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, +) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") - private val remoteControlClient: RemoteControlClient by inject() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val _showPicker = MutableStateFlow(false) - val showPicker: StateFlow = _showPicker.asStateFlow() + private val _pendingRequest = MutableStateFlow(null) + val pendingRequest: StateFlow = _pendingRequest.asStateFlow() - private var pendingAction: (() -> Unit)? = null - - /** - * Checks if a remote device is connected. - */ fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } - /** - * Wraps a playback action. If a remote device is connected, shows the picker. - * Otherwise, executes the action immediately. - * - * @param action The action to execute if playing locally - */ - fun wrapPlaybackAction(action: () -> Unit) { - if (isRemoteConnected()) { - pendingAction = action - _showPicker.value = true - } else { - action() - } + // ---------- Collection actions ---------- + + fun requestCollectionPlay( + type: RemoteCollectionType, + id: String, + title: String, + startTrack: MetadataTrack? = null, + ) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack)) } - /** - * Called when the user chooses to play locally. - */ + fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id)) + } + + fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id)) + } + + // ---------- Single track actions ---------- + + fun requestTrackAddToQueue(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track)) + } + + fun requestTrackPlayNext(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track)) + } + + // ---------- Bulk track actions ---------- + + fun requestTracksAddToQueue(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks)) + } + + fun requestTracksPlayNext(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks)) + } + + // ---------- Picker resolution ---------- + fun playLocally() { - _showPicker.value = false - pendingAction?.invoke() - pendingAction = null + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeLocally(request) } - /** - * Called when the user chooses to play on the remote device. - * Sends a play command to the remote device. - * - * @param source The source identifier (e.g., playlist ID, album ID, track ID) - */ - fun playOnRemote(source: String) { - _showPicker.value = false - pendingAction = null - - CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - remoteControlClient.sendCommand(RemoteControlCommand.Play(source)) - logger.i { "Sent play command for source: $source" } - } catch (e: Exception) { - logger.e(e) { "Failed to send play command" } - } - } + fun playOnRemote() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeOnRemote(request) } - /** - * Called when the user dismisses the picker. - */ fun dismissPicker() { - _showPicker.value = false - pendingAction = null + _pendingRequest.value = null } - /** - * Sends an add-to-queue command to the remote device. - * - * @param source The source identifier (e.g., playlist ID, album ID, track ID) - */ - fun addToQueueOnRemote(source: String) { - CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source)) - logger.i { "Sent add-to-queue command for source: $source" } - } catch (e: Exception) { - logger.e(e) { "Failed to send add-to-queue command" } + // ---------- Internals ---------- + + private fun request(request: PlaybackDestinationRequest) { + if (isRemoteConnected()) { + _pendingRequest.value = request + } else { + executeLocally(request) + } + } + + private fun executeLocally(request: PlaybackDestinationRequest) { + scope.launch { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } + + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks() + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } + + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) + } + } + } + + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } + + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + } } } } -} + + private fun executeOnRemote(request: PlaybackDestinationRequest) { + scope.launch { + try { + val command = when (request) { + is PlaybackDestinationRequest.Collection -> { + val source = when (request.type) { + RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}" + RemoteCollectionType.Album -> "spotube://album/${request.id}" + RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}" + RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" + } + when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) + } + } + + is PlaybackDestinationRequest.Track -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track) + } + + is PlaybackDestinationRequest.Tracks -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks) + } + } + remoteControlClient.sendCommand(command) + logger.i { "Sent remote ${request.action} for ${request.title}" } + } catch (e: Exception) { + logger.e(e) { "Failed to send remote playback command" } + } + } + } + + private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 2e021980..769b8c43 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -20,7 +20,6 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,8 +28,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import kotlinx.coroutines.launch import org.koin.compose.koinInject @Composable @@ -39,9 +39,9 @@ fun AlbumCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { - val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( @@ -54,10 +54,18 @@ fun AlbumCard( }, onPlay = { if (currentCollectionEntry?.id == album.id) return@PlayableCard - scope.launch { playbackHelper.playAlbum(album.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addAlbumToQueue(album.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, modifier = modifier.width(160.dp), sharedElementKey = "album_art_${album.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index 1bca2308..cf83ec60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -27,6 +27,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -37,7 +39,8 @@ fun PlaylistCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -52,10 +55,18 @@ fun PlaylistCard( }, onPlay = { if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard - scope.launch { playbackHelper.playPlaylist(playlist.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, modifier = modifier, sharedElementKey = "playlist_art_${playlist.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index f705968c..eb2b2ee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class AlbumViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -217,15 +220,28 @@ class AlbumViewModel( } fun playAlbum() { - viewModelScope.launch { playbackHelper.playAlbum(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) } fun addAlbumToQueue() { - viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) + } + + fun playAlbumNext() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title) } fun playAlbumFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Album, + id = albumId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -241,14 +257,20 @@ class AlbumViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -312,33 +334,13 @@ class AlbumViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksPlayNext(tracks, title) } fun isTrackBlacklisted(track: MetadataTrack): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index e6af958c..2baa9e9d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -27,6 +27,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -73,6 +75,7 @@ class ArtistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -247,80 +250,40 @@ class ArtistViewModel( } fun addTopTracksToQueue() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracks() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = 0, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { - val queue = audioPlayerQueue.getQueue() - val queueIndex = queue.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - } - if (queueIndex >= 0) { - audioPlayerQueue.jumpTo(queueIndex) - return@launch - } - - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - - val startPosition = entries.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }.coerceAtLeast(0) - - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = startPosition, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.ArtistTopTracks, + id = artistId, + title = artistName, + startTrack = track, + ) } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksAddToQueue(tracks, artistName) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksPlayNext(tracks, artistName) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { @@ -329,13 +292,19 @@ class ArtistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 3c412260..a5b9f4b9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -17,8 +17,10 @@ package dev.krtirtho.spotube.modules.devices +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -26,38 +28,49 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import org.koin.compose.koinInject /** - * Dialog shown when a remote device is connected and the user tries to play/add to queue. - * Allows the user to choose between playing on the local device or the remote device. + * Globally hosted dialog shown when a remote device is connected and the user + * tries to play / add to queue / play next. Lets the user choose between the + * local device and the connected remote device(s). */ @Composable -fun PlayDestinationPicker( - visible: Boolean, - onDismiss: () -> Unit, - onPlayLocally: () -> Unit, - onPlayOnRemote: () -> Unit, -) { +fun PlayDestinationPickerHost() { + val controller = koinInject() val remoteControlClient = koinInject() + val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - if (!visible) return + val pendingRequest = request ?: return val remoteDeviceName = when (val state = connectionState) { is ConnectionState.Connected -> "Remote Device (${state.host})" else -> "Remote Device" } + val actionLabel = when (pendingRequest.action) { + PlaybackDestinationAction.Play -> "Play" + PlaybackDestinationAction.AddToQueue -> "Add to queue" + PlaybackDestinationAction.PlayNext -> "Play next" + } + ThemedDialog( - onDismissRequest = onDismiss, + onDismissRequest = controller::dismissPicker, title = { Text( - text = "Play Where?", + text = "Where to $actionLabel?", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) @@ -65,24 +78,69 @@ fun PlayDestinationPicker( content = { Column( modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Choose where to play this content:", + text = "$actionLabel \"${pendingRequest.title}\" on:", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + ListRowTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "This Device", + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + + ListRowTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = remoteDeviceName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel on the connected device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) } }, actions = { - TextButton(onClick = onDismiss) { + TextButton(onClick = controller::dismissPicker) { Text("Cancel") } - TextButton(onClick = onPlayLocally) { - Text("This Device") - } - TextButton(onClick = onPlayOnRemote) { - Text(remoteDeviceName) - } }, ) -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index ed03f6f3..31949017 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -18,6 +18,8 @@ package dev.krtirtho.spotube.modules.devices import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,11 +27,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -97,101 +101,131 @@ fun RemoteControlScreen( val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - Scaffold( - topBar = { - ApplicationMainBar( - title = { Text("Remote Control") }, - backButton = true, - actions = { - GhostIconButton( - onClick = viewModel::toggleQueueVisibility, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = "Queue", - tint = if (isQueueVisible) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - GhostIconButton( - onClick = { - viewModel.disconnect() - onDisconnect() + // Center the mobile-inspired layout and limit its width so it doesn't + // stretch awkwardly on large screens. + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Scaffold( + modifier = Modifier + .fillMaxHeight(), + topBar = { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + GhostIconButton( + onClick = viewModel::toggleQueueVisibility, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + GhostIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + } + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect", + ) } - ) { - Icon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Disconnect", - ) } - } - ) - } - ) { padding -> - when (connectionState) { - is ConnectionState.Connected -> { - RemoteControlContent( - playerState = playerState, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onSetVolume = viewModel::setVolume, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) ) } - is ConnectionState.Connecting -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connecting...") + ) { padding -> + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) + ) } - } - is ConnectionState.Disconnected -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Disconnected") + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } } - } - is ConnectionState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } } } } } - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize(), - ) { - RemoteQueueSection( - queueState = queueState, - onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem, + // Click-outside scrim for the sliding queue sheet on large screens. + // (The ModalBottomSheet variant has its own built-in scrim.) + if (isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { viewModel.toggleQueueVisibility() }, + ) ) } + + // Keep the sliding sheet above the AppLargePlayer on large screens. + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + ) { + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { viewModel.toggleQueueVisibility() }, + modifier = Modifier.fillMaxSize(), + ) { + RemoteQueueSection( + queueState = queueState, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem, + ) + } + } } @Composable @@ -206,227 +240,230 @@ private fun RemoteControlContent( onCycleLoopMode: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(32.dp)) - - // Album art - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - if (playerState.currentTrackCoverUrl != null) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = "Album cover", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(48.dp), - ) - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Track info + Box (modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .widthIn(max = 480.dp) + .align(Alignment.TopCenter), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = playerState.currentTrackTitle ?: "Unknown Track", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Spacer(modifier = Modifier.height(32.dp)) - Spacer(modifier = Modifier.height(8.dp)) + // Album art + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + if (playerState.currentTrackCoverUrl != null) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } + } + } - Text( - text = playerState.currentTrackArtists ?: "Unknown Artist", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Spacer(modifier = Modifier.height(32.dp)) - if (playerState.currentTrackAlbum != null) { - Spacer(modifier = Modifier.height(4.dp)) + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( - text = playerState.currentTrackAlbum!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + text = playerState.currentTrackTitle ?: "Unknown Track", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = playerState.currentTrackArtists ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + + if (playerState.currentTrackAlbum != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playerState.currentTrackAlbum!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(32.dp)) - // Seek bar - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Slider( - value = playerState.positionMs.toFloat(), - onValueChange = { onSeek(it.toLong()) }, - valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + // Seek bar + Column( modifier = Modifier.fillMaxWidth(), - ) + ) { + Slider( + value = playerState.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(playerState.positionMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(playerState.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = formatDuration(playerState.positionMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Shuffle + IconButton( + onClick = onToggleShuffle, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Skip previous + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = "Previous", + modifier = Modifier.size(32.dp), + ) + } + + // Play/Pause + val baseTheme = LocalBaseUITheme.current + val circlePrimaryIconTheme = remember(baseTheme) { + baseTheme.iconButtons.primary.copy( + shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) + ) + } + PrimaryIconButton( + onClick = onTogglePlayPause, + modifier = Modifier.size(72.dp), + theme = circlePrimaryIconTheme, + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(40.dp), + ) + } + + // Skip next + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = "Next", + modifier = Modifier.size(32.dp), + ) + } + + // Loop mode + IconButton( + onClick = onCycleLoopMode, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Volume control + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxVolumeHigh, + contentDescription = "Volume", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) - Text( - text = formatDuration(playerState.durationMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + + Slider( + value = playerState.volume, + onValueChange = onSetVolume, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), ) } + + Spacer(modifier = Modifier.height(32.dp)) } - - Spacer(modifier = Modifier.height(24.dp)) - - // Playback controls - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - // Shuffle - IconButton( - onClick = onToggleShuffle, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = "Shuffle", - tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - - // Skip previous - IconButton( - onClick = onSkipPrevious, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxPrevious, - contentDescription = "Previous", - modifier = Modifier.size(32.dp), - ) - } - - // Play/Pause - val baseTheme = LocalBaseUITheme.current - val circlePrimaryIconTheme = remember(baseTheme) { - baseTheme.iconButtons.primary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) - } - PrimaryIconButton( - onClick = onTogglePlayPause, - modifier = Modifier.size(72.dp), - theme = circlePrimaryIconTheme, - ) { - Icon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp), - ) - } - - // Skip next - IconButton( - onClick = onSkipNext, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = "Next", - modifier = Modifier.size(32.dp), - ) - } - - // Loop mode - IconButton( - onClick = onCycleLoopMode, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = "Loop mode", - tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Volume control - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxVolumeHigh, - contentDescription = "Volume", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) - - Slider( - value = playerState.volume, - onValueChange = onSetVolume, - valueRange = 0f..1f, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = Modifier.height(32.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index dfbc15e1..7a5c0d58 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -38,7 +38,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.devices.PlayDestinationPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet @@ -60,7 +59,6 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle() var showEditPlaylist by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) } @@ -171,13 +169,6 @@ fun PlaylistScreen( viewModel.refresh() }, ) - - PlayDestinationPicker( - visible = showPlayDestinationPicker, - onDismiss = viewModel::dismissPlayPicker, - onPlayLocally = viewModel::playLocally, - onPlayOnRemote = viewModel::playOnRemote, - ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index e0e3a054..8380e8af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction @@ -117,8 +118,6 @@ class PlaylistViewModel( private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() - val showPlayDestinationPicker = remotePlaybackController.showPicker - private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) private val _showAddToPlaylistPicker = MutableStateFlow(false) val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() @@ -227,35 +226,28 @@ class PlaylistViewModel( } fun playPlaylist() { - remotePlaybackController.wrapPlaybackAction { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) } fun addPlaylistToQueue() { - if (remotePlaybackController.isRemoteConnected()) { - remotePlaybackController.addToQueueOnRemote(playlistId) - } else { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) + } + + fun playPlaylistNext() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title) } fun playPlaylistFromTrack(track: MetadataTrack) { - remotePlaybackController.wrapPlaybackAction { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } - } - } - - fun playLocally() { - remotePlaybackController.playLocally() - } - - fun playOnRemote() { - remotePlaybackController.playOnRemote(playlistId) - } - - fun dismissPlayPicker() { - remotePlaybackController.dismissPicker() + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Playlist, + id = playlistId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -298,14 +290,20 @@ class PlaylistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -381,33 +379,13 @@ class PlaylistViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksPlayNext(tracks, title) } val savedTrackIds diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 41fa1bc7..72d52113 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -34,6 +34,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class SavedTracksViewModel( private val libraryRepository: LibraryRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -200,15 +203,20 @@ class SavedTracksViewModel( } fun playSavedTracks() { - viewModelScope.launch { playbackHelper.playSavedTracks() } + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun addSavedTracksToQueue() { - viewModelScope.launch { playbackHelper.addSavedTracksToQueue() } + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun playSavedTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) } + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.SavedTracks, + id = SAVED_TRACKS_COLLECTION_ID, + title = "Saved Tracks", + startTrack = track, + ) } fun refresh() { @@ -224,14 +232,20 @@ class SavedTracksViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -299,33 +313,11 @@ class SavedTracksViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks") } suspend fun isSavedTracks(trackIds: List): List { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 0428244f..b8f699d8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.ChipTab @@ -121,6 +122,7 @@ private val GridMinCellSize = 180.dp fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val audioPlayerQueue: AudioPlayerQueue = koinInject() val shareService: ShareService = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() val libraryRepository: LibraryRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject() @@ -171,14 +173,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -237,33 +245,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } fun bulkAddToQueue(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") } fun bulkPlayNext(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } Scaffold( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index e41b2ef5..14fe94f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -70,6 +70,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost +import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -118,6 +119,7 @@ fun AppShell( } ConnectionRequestDialogHost() + PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar() From 0eb07ef9b6486b166f89d638535a3cb7413f02a1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 14:17:37 +0600 Subject: [PATCH 009/132] feat(webrtc): update ICE server configuration and enhance logging for peer connection events --- composeApp/Cargo.lock | 517 ++++++++++++++++-- composeApp/Cargo.toml | 5 +- .../spotube/core/jam/JamSessionService.kt | 42 +- .../spotube/modules/home/HomeScreen.kt | 26 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 3 + composeApp/src/commonMain/rust/webrtc_p2p.rs | 24 +- 6 files changed, 549 insertions(+), 68 deletions(-) diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 428f5064..1897105f 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -29,6 +29,20 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -198,12 +212,24 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -243,6 +269,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -316,6 +351,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.3" @@ -350,6 +394,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -361,6 +416,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "cipher" version = "0.4.4" @@ -369,6 +437,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -379,6 +448,7 @@ dependencies = [ "bytes", "discord-rich-presence", "lofty", + "log", "parking_lot", "rtc", "thiserror 2.0.18", @@ -396,6 +466,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -453,6 +529,18 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -473,12 +561,49 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -520,6 +645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -550,6 +676,41 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -592,6 +753,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -722,6 +899,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -747,6 +925,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" @@ -764,6 +952,17 @@ dependencies = [ "scroll", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -782,6 +981,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -911,6 +1119,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1158,6 +1367,36 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1203,6 +1442,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1215,12 +1463,45 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1236,6 +1517,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1308,7 +1598,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1360,6 +1650,16 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1406,15 +1706,16 @@ dependencies = [ [[package]] name = "rtc" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9005c36795ad076abd36db3ea9ae0275a60395944647d58c1f2bc3e118dddba" dependencies = [ "bytes", "hex", "log", - "pem", "rand", "rcgen", - "rtc-crypto", + "ring", "rtc-datachannel", "rtc-dtls", "rtc-ice", @@ -1433,31 +1734,16 @@ dependencies = [ "sansio", "serde", "serde_json", + "sha2", "unicase", "url", - "x509-parser 0.16.0", -] - -[[package]] -name = "rtc-crypto" -version = "0.21.0-beta.1" -dependencies = [ - "aes", - "ccm", - "ctr", - "hmac", - "md-5", - "rand", - "ring", - "sha1", - "subtle", - "thiserror 2.0.18", - "zeroize", ] [[package]] name = "rtc-datachannel" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b61c7b8e9892094cba8a6c7fb8120ae863d32d432b00e560177b3d7ae6d2c6" dependencies = [ "bytes", "log", @@ -1468,30 +1754,47 @@ dependencies = [ [[package]] name = "rtc-dtls" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c737b1dd17a0ff63884a2f466b9588f0cffadfce5a724c0c368764d7c27f9c74" dependencies = [ + "aes", "bytecheck", "byteorder", "bytes", + "cbc", + "ccm", + "chacha20poly1305", "der-parser 9.0.0", + "hmac", "log", - "pem", + "p256", + "p384", + "rand", + "rand_core 0.6.4", "rcgen", + "ring", "rkyv", - "rtc-crypto", "rtc-shared", "rustls", + "sec1", + "sha1", + "sha2", + "subtle", + "x25519-dalek", "x509-parser 0.16.0", ] [[package]] name = "rtc-ice" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c06eeabd250a7693e1e8b28222b78c4a81a7c6ca7fe3cb99bbdff2f6c0ff0ab" dependencies = [ "bytes", "crc", "log", - "rtc-crypto", + "rand", "rtc-mdns", "rtc-shared", "rtc-stun", @@ -1503,19 +1806,35 @@ dependencies = [ [[package]] name = "rtc-interceptor" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec2776ab86c0c03c3de8ec742aa13ccc3b2c82317590d0624f251bd37eae3b9" dependencies = [ "log", "rand", + "rtc-interceptor-derive", "rtc-rtcp", "rtc-rtp", "rtc-shared", "sansio", ] +[[package]] +name = "rtc-interceptor-derive" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc31da3875839bc18e00997354a582e6decacdb23d39f8c004fc4fe2fd3fcd8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "rtc-mdns" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cd53120b83321c8b8310cce9512c685d2d6356acef34647a99ea420e6f2249" dependencies = [ "bytes", "log", @@ -1526,7 +1845,9 @@ dependencies = [ [[package]] name = "rtc-media" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0411e44fcdc1363487ae0c6a4612ec908b9b09175d05499c5de0b3c81f0b5e5" dependencies = [ "byteorder", "bytes", @@ -1538,7 +1859,9 @@ dependencies = [ [[package]] name = "rtc-rtcp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fceaadac17b114140368dd68478d186ccfc293970f79cb8dd28f94dbbc0300be" dependencies = [ "bytes", "rtc-shared", @@ -1546,7 +1869,9 @@ dependencies = [ [[package]] name = "rtc-rtp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "babb45ac2263340e2bb412d1921aa07ca441ae9b5589477221d1a50d98750ba3" dependencies = [ "bytes", "memchr", @@ -1557,7 +1882,9 @@ dependencies = [ [[package]] name = "rtc-sctp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2513391b524b88b041277faba617e0531ad726729861ea8933015393fd68ab05" dependencies = [ "bytes", "crc32c", @@ -1571,7 +1898,9 @@ dependencies = [ [[package]] name = "rtc-sdp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d136a422965289e3d32d8e110789d8fa2ac669a4474a4084fa1eeac32758ee" dependencies = [ "rand", "rtc-shared", @@ -1580,12 +1909,19 @@ dependencies = [ [[package]] name = "rtc-shared" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce81c72adbce3a2f2ad8e2b97674d2482be83cb3bfd9a153341b04b1a87ca24" dependencies = [ + "aes", + "aes-gcm", "bitflags 1.3.2", "bytes", "nix", + "p256", "rand", + "rcgen", + "sec1", "serde", "substring", "thiserror 2.0.18", @@ -1595,38 +1931,50 @@ dependencies = [ [[package]] name = "rtc-srtp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b880d5f6d436b3215c7bcfd3fb5ffe2a752fea0236440ccd98b6bf1c3561df8" dependencies = [ + "aes", "byteorder", "bytes", - "rtc-crypto", + "ctr", + "hmac", + "ring", "rtc-rtcp", "rtc-rtp", "rtc-shared", + "sha1", + "subtle", ] [[package]] name = "rtc-stun" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06b19c36a200df6071b92a25583a19ab0f9c6d5ad036d8c3ff5d392802cc926e" dependencies = [ "base64", "bytes", "crc", "lazy_static", + "md-5", "rand", - "rtc-crypto", + "ring", "rtc-shared", "sansio", + "subtle", "url", ] [[package]] name = "rtc-turn" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf0b5fbb94085c86be7277c38ae8f1c21ec75ab47ba975be0984409732020dd" dependencies = [ "bytes", "log", - "rtc-crypto", "rtc-shared", "rtc-stun", "sansio", @@ -1741,6 +2089,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1816,12 +2178,33 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1868,6 +2251,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2217,6 +2610,16 @@ dependencies = [ "weedle2", ] +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2320,7 +2723,9 @@ dependencies = [ [[package]] name = "webrtc" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3daa8f2f6366331ae3275a6c02a855c6fb3faa1d16960498d7daaf61c96e76bd" dependencies = [ "async-broadcast", "async-channel", @@ -2468,6 +2873,18 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -2562,6 +2979,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 68dbc246..900cd117 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -9,8 +9,9 @@ lofty = "0.24.0" discord-rich-presence = "1.1.0" thiserror = "2.0" parking_lot = "0.12" -webrtc = { path = "../build/webrtc-rs" } -rtc = { path = "../build/webrtc-rs/rtc" } +log = "0.4" +webrtc = "0.20.4" +rtc = "0.20.4" async-trait = "0.1" bytes = "1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt index c85db931..bfcde591 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -121,7 +121,7 @@ class JamSessionService( log.i { "Generating invite $inviteId" } val pc = createWebrtcPeerConnection( - iceServers = listOf(defaultIceServer()), + iceServers = defaultIceServers(), handler = guestEventHandler(inviteId), ) @@ -179,7 +179,7 @@ class JamSessionService( val participantName = resolveParticipantName(defaultPrefix = "Guest") val pc = createWebrtcPeerConnection( - iceServers = listOf(defaultIceServer()), + iceServers = defaultIceServers(), handler = eventHandler, ) @@ -254,10 +254,28 @@ class JamSessionService( sendMessage(JamMessage.SuggestPlaylist(tracks)) } - private fun defaultIceServer() = IceServerConfig( - urls = listOf("stun:stun.l.google.com:19302"), - username = "", - credential = "", + /** + * ICE servers for global peer-to-peer jam sessions: multiple STUN servers for + * NAT traversal plus a TURN relay for symmetric NATs and strict firewalls. + * Unreachable servers no longer stall offer/answer creation — the webrtc + * driver completes gathering once every STUN client has answered or timed out, + * and [WebrtcPeerConnection] bounds the wait anyway. + */ + private fun defaultIceServers(): List = listOf( + IceServerConfig( + urls = listOf( + "stun:stun.cloudflare.com:3478", + "stun:stun1.l.google.com:19302", + "stun:stun.l.google.com:19302", + ), + username = "", + credential = "", + ), + IceServerConfig( + urls = listOf("turn:openrelay.metered.ca:80"), + username = "openrelayproject", + credential = "openrelayproject", + ), ) private suspend fun resolveParticipantName(defaultPrefix: String): String { @@ -271,10 +289,12 @@ class JamSessionService( * attributed back to that guest (needed for kick-on-leave and targeted sends). */ private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) {} + override fun onIceCandidate(candidate: String) { + log.i { "[$guestId] ICE candidate: $candidate" } + } override fun onIceGatheringStateChange(state: String) { - log.d { "[$guestId] ICE gathering state: $state" } + log.i { "[$guestId] ICE gathering state: $state" } } override fun onConnectionStateChange(state: String) { @@ -295,10 +315,12 @@ class JamSessionService( } private val eventHandler = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) {} + override fun onIceCandidate(candidate: String) { + log.i { "ICE candidate: $candidate" } + } override fun onIceGatheringStateChange(state: String) { - log.d { "ICE gathering state: $state" } + log.i { "ICE gathering state: $state" } } override fun onConnectionStateChange(state: String) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index 012e6947..67924aaf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -128,17 +128,19 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { Text("Browse") }, actions = { - IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = "Devices", - ) - } - IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) { - Icon( - imageVector = Iconsax.User, - contentDescription = "Group Jam", - ) + if (!isDesktop) { + IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = "Devices", + ) + } + IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) { + Icon( + imageVector = Iconsax.User, + contentDescription = "Group Jam", + ) + } } }, ) @@ -355,7 +357,7 @@ private fun HomeSection( if (!subtitle.isNullOrEmpty()) Text( text = subtitle, style = MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Medium, ), modifier = Modifier.padding(horizontal = 16.dp), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index 26280783..bb3cc055 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import org.koin.compose.viewmodel.koinViewModel @Composable @@ -61,6 +62,7 @@ fun JamScreen( ) { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current Scaffold( topBar = { @@ -75,6 +77,7 @@ fun JamScreen( .fillMaxSize() .padding(innerPadding) .padding(16.dp) + .padding(bottom = shellBottomInset) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs index ba3ed9d9..3b60d756 100644 --- a/composeApp/src/commonMain/rust/webrtc_p2p.rs +++ b/composeApp/src/commonMain/rust/webrtc_p2p.rs @@ -16,9 +16,12 @@ */ use std::sync::Arc; +use std::time::Duration; use parking_lot::Mutex; +use rtc::ice::mdns::MulticastDnsMode; use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors; +use rtc::peer_connection::configuration::setting_engine::SettingEngine; use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit}; use webrtc::peer_connection::{ MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, @@ -111,6 +114,13 @@ pub async fn create_webrtc_peer_connection( ) .build(); + // mDNS adds a multicast UDP socket per peer connection. On some platforms + // (notably Android) that socket can stall and ICE gathering then never + // completes. Real-IP host candidates (no mDNS) work fine alongside STUN/TURN, + // so mDNS is disabled. + let mut setting_engine = SettingEngine::default(); + setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled); + let (gather_tx, gather_rx) = channel::<()>(1); let pc_handler = Arc::new(PeerHandlerBridge { handler: Arc::clone(&handler), @@ -119,6 +129,7 @@ pub async fn create_webrtc_peer_connection( let pc = PeerConnectionBuilder::new() .with_configuration(config) + .with_setting_engine(setting_engine) .with_media_engine(media_engine) .with_interceptor_registry(registry) .with_handler(pc_handler) @@ -138,9 +149,20 @@ impl WebrtcPeerConnection { /// Waits for ICE gathering to reach `Complete` so the local SDP includes all /// candidates (non-trickle exchange). Must be called after `set_local_description`, /// which is what starts gathering. + /// + /// Bounded by a timeout so a stalled gatherer (e.g. a platform that never reports + /// completion) can never hang `create_offer`/`create_answer` forever — the SDP + /// with the candidates gathered so far is returned instead. async fn wait_for_ice_gathering(&self) { let mut gather_rx = self.gather_rx.lock().clone(); - let _ = gather_rx.recv().await; + match tokio::time::timeout(Duration::from_secs(5), gather_rx.recv()).await { + Ok(_) => {} + Err(_) => { + log::warn!( + "ICE gathering did not complete within 5s; returning SDP with the candidates gathered so far" + ); + } + } } } From 60e38f48688f798940f09fd8203615099ac6056d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 15:46:56 +0600 Subject: [PATCH 010/132] feat(jam-session): add connection state management and update UI feedback for session status --- .../spotube/core/jam/JamSessionService.kt | 15 +++++- .../krtirtho/spotube/modules/jam/JamScreen.kt | 46 ++++++++++++------- .../spotube/modules/jam/JamViewModel.kt | 18 ++++++-- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt index bfcde591..b23b25f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -74,6 +74,9 @@ class JamSessionService( private val _localParticipantId = MutableStateFlow(null) val localParticipantId: StateFlow = _localParticipantId.asStateFlow() + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) val incomingMessages = _incomingMessages.asSharedFlow() @@ -174,7 +177,7 @@ class JamSessionService( return true } - suspend fun joinSession(offerSdp: String): String { + suspend fun joinSession(offerSdp: String, hostName: String? = null): String { log.i { "Joining jam session" } val participantName = resolveParticipantName(defaultPrefix = "Guest") @@ -186,6 +189,13 @@ class JamSessionService( hostConnection = pc _role.value = JamRole.Guest _localParticipantId.value = "guest" + _participants.value = listOf( + JamParticipant( + id = "host", + displayName = hostName?.ifBlank { null } ?: "Host", + isHost = true, + ) + ) _isActive.value = true // The data channel arrives in-band from the host's offer via on_data_channel; @@ -226,6 +236,7 @@ class JamSessionService( _role.value = null _participants.value = emptyList() _isActive.value = false + _isConnected.value = false _localParticipantId.value = null } @@ -303,6 +314,7 @@ class JamSessionService( override fun onDataChannelOpen(label: String) { log.i { "[$guestId] Data channel '$label' open" } + _isConnected.value = true } override fun onDataChannelMessage(label: String, data: String) { @@ -329,6 +341,7 @@ class JamSessionService( override fun onDataChannelOpen(label: String) { log.i { "Data channel '$label' open" } + _isConnected.value = true } override fun onDataChannelMessage(label: String, data: String) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index bb3cc055..84b0e737 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -295,23 +295,35 @@ private fun GuestSessionView( ParticipantsSection(state.participants) val answerLink = state.answerLink - if (answerLink == null) { - Text( - text = "Connecting to the session...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - text = "Almost there! Send your answer back to the host:", - style = MaterialTheme.typography.titleSmall, - ) - ShareableLinkBox( - label = "Answer link", - link = answerLink, - onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, - onShare = { onShare(answerLink) }, - ) + when { + state.isConnected -> { + Text( + text = "Connected to the session", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + answerLink == null -> { + Text( + text = "Connecting to the session...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + Text( + text = "Almost there! Send your answer back to the host:", + style = MaterialTheme.typography.titleSmall, + ) + ShareableLinkBox( + label = "Answer link", + link = answerLink, + onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, + onShare = { onShare(answerLink) }, + ) + } } LeaveButton(onLeave) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt index 2033c322..0b831839 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.launch data class JamUiState( val isActive: Boolean = false, + val isConnected: Boolean = false, val role: JamRole? = null, val participants: List = emptyList(), /** Host: deep link containing this session's SDP offer, ready to share. */ @@ -69,6 +70,7 @@ class JamViewModel( _uiState.update { it.copy( isActive = active, + isConnected = jamSession.isConnected.value, role = jamSession.role.value, participants = jamSession.participants.value, inviteLink = if (!active) null else it.inviteLink, @@ -84,6 +86,11 @@ class JamViewModel( _uiState.update { it.copy(participants = participants) } } } + viewModelScope.launch { + jamSession.isConnected.collect { connected -> + _uiState.update { it.copy(isConnected = connected) } + } + } viewModelScope.launch { deepLinks.pendingLink.collect { link -> handleDeepLink(link) @@ -119,16 +126,17 @@ class JamViewModel( fun joinWithIncomingInvite() { val sdp = _uiState.value.incomingOfferSdp ?: return - join(sdp) + join(sdp, _uiState.value.incomingHostName) } fun joinWithPasted(input: String) { - val sdp = JamInviteCodec.extractSdp(input) + val parsed = JamInviteCodec.parse(input) + val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input) if (sdp == null) { _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") } return } - join(sdp) + join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName) } /** @@ -171,10 +179,10 @@ class JamViewModel( _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) } } - private fun join(offerSdp: String) { + private fun join(offerSdp: String, hostName: String? = null) { viewModelScope.launch { runCatching { - val answer = jamSession.joinSession(offerSdp) + val answer = jamSession.joinSession(offerSdp, hostName) JamInviteCodec.buildGuestAnswer(localName(), answer) }.onSuccess { link -> _uiState.update { From 9ec7704d7baf4b01cedc02f2e52ed1858e9429c6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 5 Sep 2026 09:38:44 +0600 Subject: [PATCH 011/132] feat(jam-session): enhance jam session functionality with improved playback synchronization and error handling --- .../core/audioplayer/AudioPlayer.android.kt | 19 +- .../krtirtho/spotube/media/PlaybackService.kt | 11 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 15 +- .../krtirtho/spotube/core/jam/JamProtocol.kt | 51 ++- .../spotube/core/jam/JamSessionService.kt | 223 ++++++++++-- .../spotube/core/jam/QueueSyncManager.kt | 294 ++++++++++++--- .../core/playback/CollectionPlaybackHelper.kt | 16 + .../core/remote/RemotePlaybackController.kt | 51 ++- .../modules/devices/PlayDestinationPicker.kt | 87 +++-- .../krtirtho/spotube/modules/jam/JamScreen.kt | 343 +++++++++++++++++- .../spotube/modules/jam/JamViewModel.kt | 198 ++++++++++ composeApp/src/commonMain/rust/webrtc_p2p.rs | 47 ++- 12 files changed, 1226 insertions(+), 129 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt index 899cd58f..a002f398 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt @@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.audioplayer import android.content.Context import android.content.Intent import android.os.Build +import android.util.Log import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaMetadata @@ -51,11 +52,19 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val appContext: Context = (context as Context).applicationContext private fun ensureServiceStarted() { - val intent = Intent(appContext, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - appContext.startForegroundService(intent) - } else { - appContext.startService(intent) + // On Android 12+ starting a foreground service from the background throws + // (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or + // remote control applies playback while the app is backgrounded. Never let + // that crash the app; playback itself runs in-process without the service. + try { + val intent = Intent(appContext, PlaybackService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + } catch (e: Exception) { + Log.w("AudioPlayer", "Failed to start playback service", e) } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt index 6db62be6..3c49b84c 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt @@ -86,7 +86,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent { .setOngoing(true) .build() - startForeground(NOTIFICATION_ID, notification) + // On Android 15+ a service created from the background (e.g. by a jam + // sync or queue restoration) hits this in startForeground instead of at + // the startForegroundService call site. Don't crash: playback keeps + // running in-process, just without the notification until the app is + // foregrounded and the service can start properly. + try { + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Log.w(TAG, "startForeground not allowed; continuing without notification", e) + } librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 1b3d4f3c..8bf06e4f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -183,7 +183,16 @@ val sharedModules = module { viewModelOf(::BlacklistViewModel) viewModel { DevicesViewModel(get()) } viewModelOf(::RemoteControlViewModel) - viewModelOf(::JamViewModel) + viewModel { + JamViewModel( + jamSession = get(), + deepLinks = get(), + shareService = get(), + settingsProvider = get(), + audioPlayer = get(), + audioPlayerQueue = get(), + ) + } // Album singleOf(::AlbumRepository) @@ -229,8 +238,8 @@ val sharedModules = module { single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } - single { RemotePlaybackController(get(), get(), get(), get()) } - single { JamSessionService(get(), get()) } + single { RemotePlaybackController(get(), get(), get(), get(), get()) } + single { JamSessionService(get(), get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } single { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt index ee8808c8..01fafa94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -17,8 +17,10 @@ package dev.krtirtho.spotube.core.jam +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.MediaItem +import dev.krtirtho.spotube.core.audioplayer.QueueEntry import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -72,6 +74,13 @@ sealed class JamMessage { @SerialName("participantList") data class ParticipantList(val participants: List) : JamMessage() + @Serializable + @SerialName("kick") + data class Kick( + val participantId: String, + val reason: String = "kicked", + ) : JamMessage() + @Serializable @SerialName("leave") data class Leave(val reason: String = "user_left") : JamMessage() @@ -123,6 +132,7 @@ sealed class PlaybackCmd { @Serializable data class JamMediaItem( val url: String, + val trackId: String = "", val title: String, val artist: String, val album: String, @@ -131,6 +141,45 @@ data class JamMediaItem( val protocol: String, ) { companion object { + fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { + is QueueEntry.StreamingTrack -> JamMediaItem( + url = "", + trackId = entry.track.id, + title = entry.track.title, + artist = entry.track.artists.joinToString(", ") { it.name }, + album = entry.track.album?.title.orEmpty(), + durationMs = entry.track.durationMs, + coverUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = entry.protocol.name, + ) + + is QueueEntry.LocalTrack -> JamMediaItem( + url = entry.url, + trackId = "", + title = entry.name, + artist = entry.artists.joinToString(", "), + album = entry.album.orEmpty(), + durationMs = entry.duration, + coverUrl = "", + protocol = "PROGRESSIVE", + ) + } + + fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( + url = "", + trackId = track.id, + title = track.title, + artist = track.artists.joinToString(", ") { it.name }, + album = track.album?.title.orEmpty(), + durationMs = track.durationMs, + coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = "PROGRESSIVE", + ) + fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( url = item.url, title = item.title, @@ -149,7 +198,7 @@ data class JamMediaItem( coverURL = item.coverUrl, url = item.url, protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol - .valueOf(item.protocol), + .valueOf(item.protocol.ifBlank { "PROGRESSIVE" }), ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt index b23b25f6..d14cbca6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.jam import co.touchlab.kermit.Logger import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope @@ -49,13 +50,32 @@ data class JamInvite( val sdp: String, ) +/** + * Owns the peer connections of a jam session (star topology: host relays state + * to all guests) and the hello/welcome handshake, participant bookkeeping and + * kick/ban. Playback & queue synchronization itself is delegated to + * [QueueSyncManager], which runs while a session is active. + */ class JamSessionService( private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, private val settingsProvider: SettingsProvider, ) : KoinComponent { val logger by injectLogger() private val log = Logger.withTag("JamSessionService") + /** + * Playback/queue synchronization. Owned by this service (not a Koin bean) so + * the two don't form a circular dependency; it's started/stopped with the + * session lifecycle. + */ + private val queueSyncManager = QueueSyncManager( + audioPlayer = audioPlayer, + audioPlayerQueue = audioPlayerQueue, + jamSession = this, + settingsProvider = settingsProvider, + ) + private val json = Json { ignoreUnknownKeys = true classDiscriminator = "type" @@ -91,23 +111,36 @@ class JamSessionService( /** Host side: guests whose handshake completed. Keyed by invite id. */ private val connectedGuests = mutableMapOf() + /** Host side: guest device ids, used for bans. */ + private val guestDeviceIds = mutableMapOf() + + /** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */ + private val guestConnectionStates = mutableMapOf() + + /** Host side: device ids banned for this session. */ + private val bannedDeviceIds = mutableSetOf() + /** Guest side: the single connection to the host. */ private var hostConnection: WebrtcPeerConnection? = null + private var hostDisplayName: String = "Host" + private var guestDisplayName: String = "Guest" + suspend fun createSession(): String { log.i { "Creating jam session" } - val hostName = resolveParticipantName(defaultPrefix = "Host") + hostDisplayName = resolveParticipantName(defaultPrefix = "Host") _role.value = JamRole.Host _localParticipantId.value = "host" _participants.value = listOf( JamParticipant( id = "host", - displayName = hostName, + displayName = hostDisplayName, isHost = true, ) ) _isActive.value = true + queueSyncManager.start() return generateInvite().sdp } @@ -174,12 +207,13 @@ class JamSessionService( ) } log.i { "Guest $resolvedId ($peerName) joined" } + broadcastParticipantList() return true } suspend fun joinSession(offerSdp: String, hostName: String? = null): String { log.i { "Joining jam session" } - val participantName = resolveParticipantName(defaultPrefix = "Guest") + guestDisplayName = resolveParticipantName(defaultPrefix = "Guest") val pc = createWebrtcPeerConnection( iceServers = defaultIceServers(), @@ -188,7 +222,7 @@ class JamSessionService( hostConnection = pc _role.value = JamRole.Guest - _localParticipantId.value = "guest" + _localParticipantId.value = null _participants.value = listOf( JamParticipant( id = "host", @@ -197,6 +231,7 @@ class JamSessionService( ) ) _isActive.value = true + queueSyncManager.start() // The data channel arrives in-band from the host's offer via on_data_channel; // we only answer here. @@ -210,27 +245,52 @@ class JamSessionService( val payload = json.encodeToString(JamMessage.serializer(), message) when (_role.value) { JamRole.Host -> { - val targets = if (guestId != null) { - listOfNotNull(connectedGuests[guestId]) - } else { - connectedGuests.values.toList() - } - targets.forEach { pc -> + if (guestId != null) { + val pc = connectedGuests[guestId] ?: return runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> log.w(e) { "Failed to send to guest" } } + .onFailure { e -> + log.w(e) { "Failed to send to guest $guestId" } + onSendFailure(guestId) + } + } else { + val dead = mutableListOf() + connectedGuests.forEach { (id, pc) -> + runCatching { pc.sendData(CHANNEL_LABEL, payload) } + .onFailure { e -> + log.w(e) { "Failed to send to guest $id" } + dead += id + } + } + dead.forEach { id -> onSendFailure(id) } } } JamRole.Guest -> { - hostConnection?.sendData(CHANNEL_LABEL, payload) + runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) } + .onFailure { e -> + log.w(e) { "Failed to send to host" } + } } null -> log.w { "sendMessage called while no session is active" } } } + /** + * A send to a guest failed. If that guest's connection has already given up + * (failed/closed), drop them from the session; while the connection is merely + * "connecting" the channel may simply not be open yet, so keep them. + */ + private fun onSendFailure(guestId: String) { + val state = guestConnectionStates[guestId] + if (state == "failed" || state == "closed" || state == "disconnected") { + scope.launch { removeGuest(guestId) } + } + } + suspend fun leave() { log.i { "Leaving jam session" } + queueSyncManager.stop() runCatching { sendMessage(JamMessage.Leave()) } shutdownAll() _role.value = null @@ -238,6 +298,9 @@ class JamSessionService( _isActive.value = false _isConnected.value = false _localParticipantId.value = null + guestDeviceIds.clear() + guestConnectionStates.clear() + bannedDeviceIds.clear() } suspend fun broadcastPlaybackCommand(command: PlaybackCmd) { @@ -265,12 +328,44 @@ class JamSessionService( sendMessage(JamMessage.SuggestPlaylist(tracks)) } + // ---------- Host moderation ---------- + + suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { + if (_role.value != JamRole.Host) return + log.i { "Kicking participant $participantId" } + sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId) + removeGuest(participantId) + } + + suspend fun banParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + val deviceId = guestDeviceIds[participantId] + if (deviceId != null) { + bannedDeviceIds += deviceId + log.i { "Banning device $deviceId (participant $participantId)" } + } + kickParticipant(participantId, "banned by host") + } + + private suspend fun removeGuest(guestId: String) { + val pc = connectedGuests.remove(guestId) + runCatching { pc?.shutdown() } + guestDeviceIds.remove(guestId) + guestConnectionStates.remove(guestId) + _participants.update { current -> + current.filterNot { it.id == guestId } + } + broadcastParticipantList() + } + + private suspend fun broadcastParticipantList() { + if (_role.value != JamRole.Host) return + sendMessage(JamMessage.ParticipantList(_participants.value)) + } + /** * ICE servers for global peer-to-peer jam sessions: multiple STUN servers for * NAT traversal plus a TURN relay for symmetric NATs and strict firewalls. - * Unreachable servers no longer stall offer/answer creation — the webrtc - * driver completes gathering once every STUN client has answered or timed out, - * and [WebrtcPeerConnection] bounds the wait anyway. */ private fun defaultIceServers(): List = listOf( IceServerConfig( @@ -295,6 +390,11 @@ class JamSessionService( ?: "$defaultPrefix-${randomShortId()}" } + private fun localDeviceId(): String { + return settingsProvider.settingsState.value?.remoteControlDeviceId + ?: "device-${randomShortId()}" + } + /** * Per-guest handler so messages received on a guest's connection can be * attributed back to that guest (needed for kick-on-leave and targeted sends). @@ -310,6 +410,10 @@ class JamSessionService( override fun onConnectionStateChange(state: String) { log.i { "[$guestId] Connection state: $state" } + guestConnectionStates[guestId] = state + if (state == "failed" || state == "closed") { + scope.launch { removeGuest(guestId) } + } } override fun onDataChannelOpen(label: String) { @@ -323,6 +427,9 @@ class JamSessionService( override fun onDataChannelClose(label: String) { log.i { "[$guestId] Data channel closed" } + if (_role.value == JamRole.Host) { + scope.launch { removeGuest(guestId) } + } } } @@ -337,11 +444,19 @@ class JamSessionService( override fun onConnectionStateChange(state: String) { log.i { "Connection state: $state" } + if (state == "failed" || state == "closed") { + scope.launch { leave() } + } } override fun onDataChannelOpen(label: String) { log.i { "Data channel '$label' open" } _isConnected.value = true + // Introduce ourselves so the host can fill in our name and hand us + // our participant id. + scope.launch { + sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId())) + } } override fun onDataChannelMessage(label: String, data: String) { @@ -350,6 +465,7 @@ class JamSessionService( override fun onDataChannelClose(label: String) { log.i { "Data channel closed" } + scope.launch { leave() } } } @@ -362,15 +478,44 @@ class JamSessionService( _incomingSuggestions.tryEmit(message) } + is JamMessage.Hello -> { + if (_role.value == JamRole.Host && fromGuestId != null) { + handleHello(fromGuestId, message) + } + } + + is JamMessage.Welcome -> { + if (_role.value == JamRole.Guest) { + _localParticipantId.value = message.participantId + _participants.update { current -> + current.map { participant -> + if (participant.isHost) { + participant.copy(displayName = message.hostName.ifBlank { participant.displayName }) + } else { + participant + } + } + } + log.i { "Welcome: joined as ${message.participantId}" } + } + } + + is JamMessage.ParticipantList -> { + if (_role.value == JamRole.Guest) { + _participants.value = message.participants + } + } + + is JamMessage.Kick -> { + if (_role.value == JamRole.Guest) { + log.i { "Kicked by host: ${message.reason}" } + scope.launch { leave() } + } + } + is JamMessage.Leave -> { if (_role.value == JamRole.Host && fromGuestId != null) { - val leavingPc = connectedGuests.remove(fromGuestId) - scope.launch { - runCatching { leavingPc?.shutdown() } - } - _participants.update { current -> - current.filterNot { it.id == fromGuestId } - } + scope.launch { removeGuest(fromGuestId) } } else if (_role.value == JamRole.Guest) { scope.launch { leave() } } @@ -383,6 +528,40 @@ class JamSessionService( } } + private fun handleHello(guestId: String, hello: JamMessage.Hello) { + val deviceId = hello.deviceId + if (deviceId in bannedDeviceIds) { + log.w { "Rejecting banned device $deviceId" } + scope.launch { + sendMessage( + JamMessage.Kick(guestId, "banned by host"), + guestId = guestId, + ) + removeGuest(guestId) + } + return + } + guestDeviceIds[guestId] = deviceId + _participants.update { current -> + current.map { participant -> + if (participant.id == guestId) { + participant.copy(displayName = hello.displayName.ifBlank { participant.displayName }) + } else { + participant + } + } + } + scope.launch { + sendMessage( + JamMessage.Welcome(hostDisplayName, guestId), + guestId = guestId, + ) + broadcastParticipantList() + // Give the newly joined guest the current queue + playback state. + queueSyncManager.broadcastNow() + } + } + private suspend fun shutdownAll() { pendingInvites.values.forEach { runCatching { it.shutdown() } } connectedGuests.values.forEach { runCatching { it.shutdown() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt index 550de713..104cf223 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt @@ -18,46 +18,61 @@ package dev.krtirtho.spotube.core.jam import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import kotlinx.coroutines.flow.first /** - * Manages queue synchronization between the host and the jam session. + * Keeps playback in sync across a jam session (star topology). * - * On the host: observes local playback state and broadcasts queue updates to guests. - * On the guest: receives queue updates and applies them to local playback. + * On the **host**: applies incoming playback commands and guest suggestions to the + * host's player, and broadcasts the current queue + playback state to all guests + * (on queue changes and periodically, so play/pause/seek/position propagate). * - * Conflict resolution: the host has authority. When a guest receives a queue state, - * it replaces the local queue. (and (The guest's local queue is essentially read-only - * during a jam session.) + * On the **guest**: mirrors the host's queue into the local player and applies + * playback commands. The guest's queue is read-only — the host has authority. */ class QueueSyncManager( private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, private val jamSession: JamSessionService, - private val scope: CoroutineScope, + private val settingsProvider: SettingsProvider, ) { private val log = Logger.withTag("QueueSyncManager") - private val json = Json { - ignoreUnknownKeys = true - classDiscriminator = "type" - encodeDefaults = true - } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _isSyncing = MutableStateFlow(false) val isSyncing: StateFlow = _isSyncing.asStateFlow() private var hostBroadcastJob: Job? = null + private var hostCommandJob: Job? = null private var guestApplyJob: Job? = null private var guestCommandJob: Job? = null + /** Guest side: the last applied queue snapshot, used to detect real queue changes. */ + private var lastAppliedItems: List = emptyList() + + /** Guest side: tracks the player was told to start playing from. */ + private var lastAppliedCurrentIndex = -1 + fun start() { if (_isSyncing.value) return _isSyncing.value = true @@ -75,43 +90,87 @@ class QueueSyncManager( fun stop() { _isSyncing.value = false hostBroadcastJob?.cancel() + hostCommandJob?.cancel() guestApplyJob?.cancel() guestCommandJob?.cancel() hostBroadcastJob = null + hostCommandJob = null guestApplyJob = null guestCommandJob = null + lastAppliedItems = emptyList() + lastAppliedCurrentIndex = -1 } + // ---------- Host side ---------- + private fun startHostSync() { + // Apply commands/suggestions coming from guests. + hostCommandJob = scope.launch { + jamSession.role.first { it != null } + if (jamSession.role.value != JamRole.Host) return@launch + + jamSession.incomingMessages.collect { message -> + when (message) { + is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command) + is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem)) + is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks) + else -> {} + } + } + } + + // Broadcast state on queue changes and periodically. hostBroadcastJob = scope.launch { jamSession.role.first { it != null } if (jamSession.role.value != JamRole.Host) return@launch - jamSession.broadcastQueueState( - items = audioPlayer.playlistFlow.value.map(JamMediaItem::fromMediaItem), - currentIndex = audioPlayer.playlistFlow.value.indexOf( - audioPlayer.currentMediaItemFlow.value - ).coerceAtLeast(0), - isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, - positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, - ) - - audioPlayer.playlistFlow.collect { playlist -> - audioPlayer.playerStateFlow.value.let { state -> - audioPlayer.positionFlow.value.let { position -> - jamSession.broadcastQueueState( - items = playlist.map(JamMediaItem::fromMediaItem), - currentIndex = playlist.indexOf(audioPlayer.currentMediaItemFlow.value) - .coerceAtLeast(0), - isPlaying = state == PlayerState.PLAYING, - positionMs = position.inWholeMilliseconds, - ) - } + // Queue changes (separate coroutine — collect() never returns). + launch { + audioPlayerQueue.queueFlow.collect { + broadcastCurrentState() } } + + // Periodic tick so play/pause/seek/position propagate to guests. + while (isActive) { + delay(2_000) + broadcastCurrentState() + } } } + /** Immediately pushes the current queue + playback state to all guests. */ + suspend fun broadcastNow() { + if (jamSession.role.value == JamRole.Host) { + broadcastCurrentState() + } + } + + private suspend fun broadcastCurrentState() { + val queue = audioPlayerQueue.getQueue() + val current = audioPlayerQueue.getCurrentQueueEntry() + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesEntry(current) } + } else { + -1 + } + jamSession.broadcastQueueState( + items = queue.map(JamMediaItem::fromQueueEntry), + currentIndex = currentIndex.coerceAtLeast(0), + isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + ) + } + + private suspend fun acceptSuggestion(items: List) { + if (items.isEmpty()) return + val entries = items.map { it.toQueueEntry() } + log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" } + audioPlayerQueue.addAllToQueue(entries) + } + + // ---------- Guest side ---------- + private fun startGuestSync() { guestApplyJob = scope.launch { jamSession.incomingMessages.collect { message -> @@ -130,33 +189,162 @@ class QueueSyncManager( private suspend fun applyQueueState(state: JamMessage.QueueState) { log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" } - val mediaItems = state.items.map(JamMediaItem::toMediaItem) - audioPlayer.load( - playlist = mediaItems, - autoPlay = state.isPlaying, - startPosition = state.currentIndex.coerceAtLeast(0), + + // Items that carry neither a track id nor a usable URL can't be played + // on this device — skip them instead of crashing the player. + val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + + val queueChanged = playableItems != lastAppliedItems + if (queueChanged) { + lastAppliedItems = playableItems + lastAppliedCurrentIndex = state.currentIndex + val mediaItems = playableItems.map { it.toPlayableMediaItem() } + runCatching { + audioPlayer.load( + playlist = mediaItems, + autoPlay = state.isPlaying, + startPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex.coerceAtLeast(0)), + ) + }.onFailure { e -> + log.e(e) { "Failed to apply jam queue to local player" } + } + return + } + + // Same queue: just sync playback state. Avoid seeking on every tick unless + // the drift is meaningful. + if (state.currentIndex != lastAppliedCurrentIndex) { + lastAppliedCurrentIndex = state.currentIndex + runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) } + .onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } } + } + val currentState = audioPlayer.playerStateFlow.value + if (state.isPlaying && currentState != PlayerState.PLAYING) { + audioPlayer.play() + } else if (!state.isPlaying && currentState == PlayerState.PLAYING) { + audioPlayer.pause() + } + val driftMs = kotlin.math.abs( + audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs ) + if (driftMs > POSITION_SYNC_THRESHOLD_MS) { + runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) } + .onFailure { e -> log.w(e) { "Failed to sync position" } } + } } private suspend fun applyPlaybackCommand(command: PlaybackCmd) { log.d { "Applying playback command: $command" } - when (command) { - PlaybackCmd.Play -> audioPlayer.play() - PlaybackCmd.Pause -> audioPlayer.pause() - PlaybackCmd.Toggle -> { - if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { - audioPlayer.pause() - } else { - audioPlayer.play() + runCatching { + when (command) { + PlaybackCmd.Play -> audioPlayer.play() + PlaybackCmd.Pause -> audioPlayer.pause() + PlaybackCmd.Toggle -> { + if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } } + + is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + PlaybackCmd.SkipNext -> audioPlayer.skipToNext() + PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() + is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) + is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) + is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) + is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) } - is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) - PlaybackCmd.SkipNext -> audioPlayer.skipToNext() - PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() - is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) - is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) - is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) - is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) + }.onFailure { e -> + log.w(e) { "Failed to apply playback command: $command" } } } + + // ---------- Conversions ---------- + + /** Host side: turn a suggested item into a playable queue entry. */ + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { + trackId.isNotBlank() -> QueueEntry.StreamingTrack( + track = MetadataTrack( + id = trackId, + title = title, + durationMs = durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) + ), + album = null, + thumbnails = null, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = null, + ), + url = "", + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + ) + + else -> QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = durationMs, + album = album.ifBlank { null }, + coverBytes = null, + url = url, + ) + } + + /** + * Guest side: build a playable MediaItem. Streaming tracks have their stream + * URL resolved through this device's own playback proxy (the host never sends + * usable URLs — each guest must fetch from its own plugins). + */ + private suspend fun JamMediaItem.toPlayableMediaItem(): MediaItem { + if (trackId.isNotBlank()) { + val proxyUrl = buildStreamingUrl(trackId, protocol) + return MediaItem( + title = title, + artist = artist, + album = album, + duration = kotlin.time.Duration.parse("${durationMs}ms"), + coverURL = coverUrl, + url = proxyUrl, + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + ) + } + return JamMediaItem.toMediaItem(this) + } + + private suspend fun buildStreamingUrl(trackId: String, protocol: String): String { + val port = settingsProvider.settingsState + .first() + ?.playbackProxyServerPort ?: return "" + val baseUrl = "http://127.0.0.1:$port" + val streamProtocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE) + return when (streamProtocol) { + StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" + StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" + } + } + + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } + } + + companion object { + /** Seek the guest only when its position drifts more than this from the host. */ + private const val POSITION_SYNC_THRESHOLD_MS = 3_000L + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 7441309c..da55fb38 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import dev.krtirtho.spotube.core.remote.RemoteCollectionType class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, @@ -264,6 +265,21 @@ class CollectionPlaybackHelper( } } + /** + * Resolves the tracks of a collection without loading them into the local + * queue — used to suggest a collection into a jam session from a guest. + */ + suspend fun resolveCollectionTracks(type: RemoteCollectionType, id: String): List = + when (type) { + RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks() + RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks() + RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks() + RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks() + } + + private fun List.asTracks(): List = + mapNotNull { (it as? QueueEntry.StreamingTrack)?.track } + private suspend fun fetchAllSavedTracks(): List { val allTracks = mutableListOf() var pagination = savedTracksRepository.getSavedTracks() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index a0ba5f5b..01a77c4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -21,6 +21,9 @@ import co.touchlab.kermit.Logger import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamMediaItem +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope @@ -84,6 +87,7 @@ class RemotePlaybackController( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, + private val jamSession: JamSessionService, ) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -154,6 +158,49 @@ class RemotePlaybackController( _pendingRequest.value = null } + /** + * Routes the pending request into the active jam session. On the host the jam + * queue IS the local queue, so the action runs locally; on a guest the content + * is suggested to the host, which accepts it into the shared queue. + */ + fun playOnJam() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + scope.launch { + try { + when (jamSession.role.value) { + JamRole.Host -> executeLocally(request) + JamRole.Guest -> suggestToJam(request) + null -> {} + } + } catch (e: Exception) { + logger.e(e) { "Failed to send content to jam session" } + } + } + } + + private suspend fun suggestToJam(request: PlaybackDestinationRequest) { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id) + if (tracks.isNotEmpty()) { + jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() }) + logger.i { "Suggested ${tracks.size} track(s) to the jam session" } + } + } + + is PlaybackDestinationRequest.Track -> { + jamSession.suggestTrack(request.track.toJamMediaItem()) + } + + is PlaybackDestinationRequest.Tracks -> { + if (request.tracks.isNotEmpty()) { + jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() }) + } + } + } + } + // ---------- Internals ---------- private fun request(request: PlaybackDestinationRequest) { @@ -321,4 +368,6 @@ class RemotePlaybackController( album?.id == other.album?.id && artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } } -} \ No newline at end of file +} + +private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index a5b9f4b9..6fd70573 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient @@ -39,19 +40,22 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist import org.koin.compose.koinInject /** - * Globally hosted dialog shown when a remote device is connected and the user - * tries to play / add to queue / play next. Lets the user choose between the - * local device and the connected remote device(s). + * Globally hosted dialog shown when the user tries to play / add to queue / + * play next and there is more than one place it could go (a connected remote + * device and/or an active jam session). Lets the user choose the destination. */ @Composable fun PlayDestinationPickerHost() { val controller = koinInject() val remoteControlClient = koinInject() + val jamSession = koinInject() val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + val jamActive by jamSession.isActive.collectAsStateWithLifecycle() val pendingRequest = request ?: return @@ -111,30 +115,59 @@ fun PlayDestinationPickerHost() { }, ) - ListRowTile( - onClick = controller::playOnRemote, - modifier = Modifier.fillMaxWidth(), - leading = { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - title = { - Text( - text = remoteDeviceName, - style = MaterialTheme.typography.bodyLarge, - ) - }, - subtitle = { - Text( - text = "$actionLabel on the connected device", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) + if (connectionState is ConnectionState.Connected) { + ListRowTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = remoteDeviceName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel on the connected device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + + if (jamActive) { + ListRowTile( + onClick = controller::playOnJam, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMusicPlaylist, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "Jam Session", + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel in the shared jam queue", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } } }, actions = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index 84b0e737..adc4d246 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,17 +17,25 @@ package dev.krtirtho.spotube.modules.jam +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -45,15 +53,31 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious +import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import org.koin.compose.viewmodel.koinViewModel @Composable @@ -99,16 +123,34 @@ fun JamScreen( state.role == JamRole.Host -> HostSessionView( state = state, + playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, onNewInvite = viewModel::generateNewInvite, onSubmitAnswer = viewModel::submitAnswerPasted, onShare = viewModel::share, onLeave = viewModel::leave, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onJumpTo = viewModel::jumpTo, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoop = viewModel::cycleLoopMode, + onKick = viewModel::kickParticipant, + onBan = viewModel::banParticipant, ) else -> GuestSessionView( state = state, + playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, onShare = viewModel::share, onLeave = viewModel::leave, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onJumpTo = viewModel::jumpTo, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoop = viewModel::cycleLoopMode, ) } } @@ -220,16 +262,35 @@ private fun IncomingInviteView( @Composable private fun HostSessionView( state: JamUiState, + playerState: JamPlayerUiState, onNewInvite: () -> Unit, onSubmitAnswer: (String) -> Unit, onShare: (String) -> Unit, onLeave: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onJumpTo: (Int) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoop: () -> Unit, + onKick: (String) -> Unit, + onBan: (String) -> Unit, ) { val clipboard = LocalClipboardManager.current var pastedAnswer by rememberSaveable { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants) + ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan) + + JamNowPlayingView( + playerState = playerState, + onTogglePlayPause = onTogglePlayPause, + onSkipNext = onSkipNext, + onSkipPrevious = onSkipPrevious, + onToggleShuffle = onToggleShuffle, + onCycleLoop = onCycleLoop, + ) HorizontalDivider() @@ -279,6 +340,13 @@ private fun HostSessionView( Text("Accept Answer") } + HorizontalDivider() + + JamQueueView( + queue = playerState.queue, + onJumpTo = onJumpTo, + ) + LeaveButton(onLeave) } } @@ -286,21 +354,37 @@ private fun HostSessionView( @Composable private fun GuestSessionView( state: JamUiState, + playerState: JamPlayerUiState, onShare: (String) -> Unit, onLeave: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onJumpTo: (Int) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoop: () -> Unit, ) { val clipboard = LocalClipboardManager.current Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants) + ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {}) val answerLink = state.answerLink when { state.isConnected -> { - Text( - text = "Connected to the session", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + JamNowPlayingView( + playerState = playerState, + onTogglePlayPause = onTogglePlayPause, + onSkipNext = onSkipNext, + onSkipPrevious = onSkipPrevious, + onToggleShuffle = onToggleShuffle, + onCycleLoop = onCycleLoop, + ) + + JamQueueView( + queue = playerState.queue, + onJumpTo = onJumpTo, ) } @@ -331,7 +415,231 @@ private fun GuestSessionView( } @Composable -private fun ParticipantsSection(participants: List) { +private fun JamNowPlayingView( + playerState: JamPlayerUiState, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoop: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AsyncImage( + model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(MaterialTheme.shapes.medium), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = playerState.currentTitle ?: "Nothing playing", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = playerState.currentArtist ?: "—", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatJamDuration(playerState.positionMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatJamDuration(playerState.durationMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onToggleShuffle, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + IconButton( + onClick = onSkipPrevious, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + } + IconButton( + onClick = onTogglePlayPause, + theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape), + modifier = Modifier.size(64.dp), + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(32.dp), + ) + } + IconButton( + onClick = onSkipNext, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon(Iconsax.IconsaxNext, contentDescription = "Next") + } + IconButton( + onClick = onCycleLoop, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } +} + +@Composable +private fun JamQueueView( + queue: List, + onJumpTo: (Int) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Queue (${queue.size})", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = if (expanded) "Collapse queue" else "Expand queue", + modifier = Modifier + .size(20.dp) + .graphicsLayer { rotationZ = if (expanded) 180f else 0f }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (queue.isEmpty()) { + Text( + text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (expanded) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed(queue) { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onJumpTo(index) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AsyncImage( + model = item.coverUrl.takeIf { it.isNotBlank() }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(MaterialTheme.shapes.small), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (item.isCurrent) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = item.artist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = formatJamDuration(item.durationMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + Text( + text = "Tap to view the shared queue.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ParticipantsSection( + participants: List, + isHost: Boolean, + onKick: (String) -> Unit, + onBan: (String) -> Unit, +) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Participants (${participants.size})", @@ -357,11 +665,32 @@ private fun ParticipantsSection(participants: List = emptyList(), + val currentIndex: Int = -1, + val currentTitle: String? = null, + val currentArtist: String? = null, + val currentCoverUrl: String? = null, + val isPlaying: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val shuffleEnabled: Boolean = false, + val loopMode: String = "none", +) + class JamViewModel( private val jamSession: JamSessionService, private val deepLinks: JamDeepLinkService, private val shareService: ShareService, private val settingsProvider: SettingsProvider, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, ) : ViewModel() { private val _uiState = MutableStateFlow(JamUiState()) @@ -98,6 +136,144 @@ class JamViewModel( } } + /** + * The jam player state: the shared queue + current playback, built from the + * local player (the host's queue IS the jam queue; on guests the synced + * mirror lives in the local player). + */ + val jamPlayerState: StateFlow = combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.playlistFlow, + audioPlayer.currentMediaItemFlow, + audioPlayer.playerStateFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow, + audioPlayer.loopStateFlow, + audioPlayer.shuffleModeFlow, + ) { values -> + val queue: List = values[0] as List + val currentEntry: QueueEntry? = values[1] as QueueEntry? + val playlist: List = values[2] as List + val currentItem: MediaItem? = values[3] as MediaItem? + val playerState: PlayerState = values[4] as PlayerState + val position: kotlin.time.Duration = values[5] as kotlin.time.Duration + val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration + val loop: LoopState = values[7] as LoopState + val shuffle: Boolean = values[8] as Boolean + + val isHost = jamSession.role.value == JamRole.Host + + val items: List + val currentIndex: Int + val currentTitle: String? + val currentArtist: String? + val currentCoverUrl: String? + + if (isHost) { + val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) } + val index = if (currentEntry != null) { + queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) } + } else { + -1 + } + items = queueItems.mapIndexed { i, item -> + item.toUiItem(i == index) + } + currentIndex = index + currentTitle = queueItems.getOrNull(index)?.title + currentArtist = queueItems.getOrNull(index)?.artist + currentCoverUrl = queueItems.getOrNull(index)?.coverUrl + } else { + val index = playlist.indexOf(currentItem) + items = playlist.mapIndexed { i, item -> + JamMediaItem.fromMediaItem(item).toUiItem(i == index) + } + currentIndex = index + currentTitle = currentItem?.title + currentArtist = currentItem?.artist + currentCoverUrl = currentItem?.coverURL + } + + JamPlayerUiState( + queue = items, + currentIndex = currentIndex, + currentTitle = currentTitle, + currentArtist = currentArtist, + currentCoverUrl = currentCoverUrl, + isPlaying = playerState == PlayerState.PLAYING, + positionMs = position.inWholeMilliseconds, + durationMs = duration.inWholeMilliseconds, + shuffleEnabled = shuffle, + loopMode = loop.name.lowercase(), + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState()) + + // ---------- Playback controls ---------- + + fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle) + + fun skipNext() = sendOrApply(PlaybackCmd.SkipNext) + + fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious) + + fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs)) + + fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index)) + + fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled)) + + fun cycleLoopMode() { + val next = when (jamPlayerState.value.loopMode) { + "none" -> "one" + "one" -> "all" + else -> "none" + } + sendOrApply(PlaybackCmd.SetLoop(next)) + } + + private fun sendOrApply(command: PlaybackCmd) { + viewModelScope.launch { + if (jamSession.role.value == JamRole.Host) { + applyCommandLocally(command) + } else { + jamSession.sendMessage(JamMessage.PlaybackCommand(command)) + } + } + } + + private suspend fun applyCommandLocally(command: PlaybackCmd) { + when (command) { + PlaybackCmd.Play -> audioPlayer.play() + PlaybackCmd.Pause -> audioPlayer.pause() + PlaybackCmd.Toggle -> { + if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + + is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + PlaybackCmd.SkipNext -> audioPlayer.skipToNext() + PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() + is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) + is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) + is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) + is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) + } + } + + // ---------- Host moderation ---------- + + fun kickParticipant(participantId: String) { + viewModelScope.launch { jamSession.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + viewModelScope.launch { jamSession.banParticipant(participantId) } + } + fun createSession() { viewModelScope.launch { runCatching { @@ -228,4 +404,26 @@ class JamViewModel( private fun localName(): String = settingsProvider.settingsState.value?.jamParticipantName.orEmpty() +} + +private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem( + id = if (trackId.isNotBlank()) trackId else url, + title = title, + artist = artist, + album = album, + durationMs = durationMs, + coverUrl = coverUrl, + isCurrent = isCurrent, +) + +private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs index 3b60d756..c40e9a69 100644 --- a/composeApp/src/commonMain/rust/webrtc_p2p.rs +++ b/composeApp/src/commonMain/rust/webrtc_p2p.rs @@ -78,7 +78,7 @@ struct DataChannelEntry { pub struct WebrtcPeerConnection { pc: Arc, handler: Arc, - channels: Mutex>, + channels: Arc>>, gather_rx: Mutex>, } @@ -122,9 +122,11 @@ pub async fn create_webrtc_peer_connection( setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled); let (gather_tx, gather_rx) = channel::<()>(1); + let channels = Arc::new(Mutex::new(Vec::new())); let pc_handler = Arc::new(PeerHandlerBridge { handler: Arc::clone(&handler), gather_tx, + channels: Arc::clone(&channels), }); let pc = PeerConnectionBuilder::new() @@ -140,7 +142,7 @@ pub async fn create_webrtc_peer_connection( Ok(Arc::new(WebrtcPeerConnection { pc: Arc::new(pc) as Arc, handler, - channels: Mutex::new(Vec::new()), + channels, gather_rx: Mutex::new(gather_rx), })) } @@ -150,17 +152,34 @@ impl WebrtcPeerConnection { /// candidates (non-trickle exchange). Must be called after `set_local_description`, /// which is what starts gathering. /// - /// Bounded by a timeout so a stalled gatherer (e.g. a platform that never reports - /// completion) can never hang `create_offer`/`create_answer` forever — the SDP - /// with the candidates gathered so far is returned instead. + /// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at + /// least one candidate has landed in the local description, a short grace period + /// is enough — the SDP must never leave candidate-less. Hard cap at 5s. async fn wait_for_ice_gathering(&self) { let mut gather_rx = self.gather_rx.lock().clone(); - match tokio::time::timeout(Duration::from_secs(5), gather_rx.recv()).await { - Ok(_) => {} - Err(_) => { + let started = std::time::Instant::now(); + + loop { + let elapsed = started.elapsed(); + if elapsed >= Duration::from_secs(5) { log::warn!( - "ICE gathering did not complete within 5s; returning SDP with the candidates gathered so far" + "ICE gathering did not complete within 5s; using the candidates gathered so far" ); + return; + } + + match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await { + Ok(Some(())) => return, // gathering complete + Ok(None) => return, // handler dropped + Err(_) => {} // timed out, keep waiting + } + + // Grace period once candidates are present, so the SDP always carries them. + if elapsed >= Duration::from_secs(1) { + let sdp = self.pc.local_description().await.map(|d| d.sdp); + if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) { + return; + } } } } @@ -251,6 +270,7 @@ impl WebrtcPeerConnection { struct PeerHandlerBridge { handler: Arc, gather_tx: webrtc::runtime::Sender<()>, + channels: Arc>>, } #[async_trait::async_trait] @@ -272,6 +292,15 @@ impl PeerConnectionEventHandler for PeerHandlerBridge { } async fn on_data_channel(&self, dc: Arc) { + // Register in-band (remote-initiated) channels so send_data() can find + // them — without this, the answering peer can never send anything. + let label = match dc.label().await { + Ok(l) => l, + Err(_) => return, + }; + self.channels + .lock() + .push(DataChannelEntry { dc: Arc::clone(&dc), label }); spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler)); } } From 571aea8d38077e1b9320b23c1f01b7d32cdfc21c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 5 Sep 2026 09:52:06 +0600 Subject: [PATCH 012/132] feat(remote-control): refactor service registration and cleanup logic for improved reliability --- .../core/audioplayer/AudioPlayer.android.kt | 7 ++- .../core/discovery/DeviceDiscoveryService.kt | 18 ++++-- .../spotube/core/jam/JamSessionService.kt | 1 - .../spotube/core/jam/QueueSyncManager.kt | 56 ++++-------------- .../core/remote/RemoteControlService.kt | 58 ++++++++++++++++--- 5 files changed, 80 insertions(+), 60 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt index a002f398..63f7f49b 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt @@ -235,7 +235,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun seekTo(position: Duration) { withContext(Dispatchers.Main) { - val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration) + val duration = exoPlayer.duration + val targetMs = if (duration > 0) { + position.inWholeMilliseconds.coerceIn(0, duration) + } else { + position.inWholeMilliseconds.coerceAtLeast(0) + } exoPlayer.seekTo(targetMs) _position.tryEmit(exoPlayer.currentPosition.milliseconds) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt index 04221d6a..25537c3f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -90,13 +90,19 @@ class DeviceDiscoveryService { deviceId: String, registerTimeoutMs: Long = 5_000, ): NetService { - val service = createNetService( - type = SERVICE_TYPE, - name = name, - port = port, - txt = mapOf(TXT_DEVICE_ID to deviceId), - ) + val service = createService(name, port, deviceId) service.register(timeoutInMs = registerTimeoutMs) return service } + + fun createService( + name: String, + port: Int, + deviceId: String, + ): NetService = createNetService( + type = SERVICE_TYPE, + name = name, + port = port, + txt = mapOf(TXT_DEVICE_ID to deviceId), + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt index d14cbca6..30cf046d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -73,7 +73,6 @@ class JamSessionService( audioPlayer = audioPlayer, audioPlayerQueue = audioPlayerQueue, jamSession = this, - settingsProvider = settingsProvider, ) private val json = Json { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt index 104cf223..23e9d26e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt @@ -23,10 +23,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -54,7 +52,6 @@ class QueueSyncManager( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, private val jamSession: JamSessionService, - private val settingsProvider: SettingsProvider, ) { private val log = Logger.withTag("QueueSyncManager") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -198,12 +195,15 @@ class QueueSyncManager( if (queueChanged) { lastAppliedItems = playableItems lastAppliedCurrentIndex = state.currentIndex - val mediaItems = playableItems.map { it.toPlayableMediaItem() } + val entries = playableItems.map { it.toQueueEntry() } runCatching { - audioPlayer.load( - playlist = mediaItems, + // Load through the queue repository (like the host does) so the + // stream proxy can resolve the tracks — it only knows tracks in + // queueFlow. + audioPlayerQueue.load( + entries = entries, autoPlay = state.isPlaying, - startPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex.coerceAtLeast(0)), + startPosition = state.currentIndex.coerceIn(0, entries.lastIndex.coerceAtLeast(0)), ) }.onFailure { e -> log.e(e) { "Failed to apply jam queue to local player" } @@ -260,9 +260,10 @@ class QueueSyncManager( } } - // ---------- Conversions ---------- - - /** Host side: turn a suggested item into a playable queue entry. */ + /** + * Build a queue entry from a jam media item. Streaming tracks carry their id + * so the device's own queue/stream proxy can resolve a playable URL later. + */ private fun JamMediaItem.toQueueEntry(): QueueEntry = when { trackId.isNotBlank() -> QueueEntry.StreamingTrack( track = MetadataTrack( @@ -296,41 +297,6 @@ class QueueSyncManager( ) } - /** - * Guest side: build a playable MediaItem. Streaming tracks have their stream - * URL resolved through this device's own playback proxy (the host never sends - * usable URLs — each guest must fetch from its own plugins). - */ - private suspend fun JamMediaItem.toPlayableMediaItem(): MediaItem { - if (trackId.isNotBlank()) { - val proxyUrl = buildStreamingUrl(trackId, protocol) - return MediaItem( - title = title, - artist = artist, - album = album, - duration = kotlin.time.Duration.parse("${durationMs}ms"), - coverURL = coverUrl, - url = proxyUrl, - protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } - .getOrDefault(StreamProtocol.PROGRESSIVE), - ) - } - return JamMediaItem.toMediaItem(this) - } - - private suspend fun buildStreamingUrl(trackId: String, protocol: String): String { - val port = settingsProvider.settingsState - .first() - ?.playbackProxyServerPort ?: return "" - val baseUrl = "http://127.0.0.1:$port" - val streamProtocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } - .getOrDefault(StreamProtocol.PROGRESSIVE) - return when (streamProtocol) { - StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" - StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" - } - } - private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean { return when { this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt index 207e2f8b..86b920d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -22,6 +22,7 @@ import com.appstractive.dnssd.NetService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.server.LocalServer import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -61,7 +62,12 @@ class RemoteControlService( val localDeviceId: StateFlow = _localDeviceId.asStateFlow() private var advertisedService: NetService? = null + + /** A registration attempt that may have been left pending by the platform. */ + private var pendingService: NetService? = null + private var registerJob: Job? = null + private var cleanupJob: Job? = null init { // Ensure a stable device id exists and is persisted up front, so discovery @@ -112,6 +118,9 @@ class RemoteControlService( val port = localServer.port.value if (!settings.allowRemoteControl || port == null) return registerJob?.cancel() + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null registerJob = scope.launch { registerLoop(settings.remoteControlDeviceName, port) } @@ -127,24 +136,53 @@ class RemoteControlService( attempt++ // The user may have toggled the setting off during backoff. if (!settingsRepository.userSettings.value.allowRemoteControl) return + val service = discoveryService.createService( + name = serviceName, + port = port, + deviceId = deviceId, + ) + pendingService = service try { - advertisedService = discoveryService.advertise( - name = serviceName, - port = port, - deviceId = deviceId, - registerTimeoutMs = REGISTER_TIMEOUT_MS, - ) + service.register(timeoutInMs = REGISTER_TIMEOUT_MS) + pendingService = null + advertisedService = service log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { + pendingService = null log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + // The library leaks the platform registration on timeout. Once the + // platform eventually completes it (success), isRegistered flips + // and unregister() will actually remove it — keep trying until then. + scheduleCleanup(service) delay(retryDelayMs(attempt)) } } } + /** + * Repeatedly tries to unregister a service whose registration attempt failed. + * The library's `unregister()` is a no-op while the platform hasn't completed + * the registration, so poll until it has (or give up after a while). + */ + private fun scheduleCleanup(service: NetService?) { + if (service == null) return + cleanupJob?.cancel() + cleanupJob = scope.launch { + repeat(REGISTER_CLEANUP_TRIES) { + delay(1_000) + runCatching { service.unregister() } + } + } + } + private suspend fun stopAdvertising() { registerJob?.cancel() registerJob = null + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null if (advertisedService != null) { runCatching { advertisedService?.unregister() } advertisedService = null @@ -172,6 +210,12 @@ class RemoteControlService( } companion object { - private const val REGISTER_TIMEOUT_MS = 4_000L + // Generous enough that the library's timeout (which leaks the platform + // registration) rarely fires on a working system — registration callbacks + // normally arrive within a second. + private const val REGISTER_TIMEOUT_MS = 10_000L + + // How long to keep polling unregister() on a failed service, in seconds. + private const val REGISTER_CLEANUP_TRIES = 15 } } \ No newline at end of file From a2b9f4178c93fc4c2afd2944a11fe4f97d910f5c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 11 Sep 2026 19:35:54 +0600 Subject: [PATCH 013/132] feat(jam-session): integrate MQTT broker configuration and enhance jam session settings --- composeApp/Cargo.lock | 2157 +---------------- composeApp/Cargo.toml | 4 - composeApp/build.gradle.kts | 6 + .../src/androidMain/AndroidManifest.xml | 11 - .../composeResources/values/strings.xml | 14 + .../kotlin/dev/krtirtho/spotube/App.kt | 8 - .../core/audioplayer/AudioPlayerModels.kt | 7 +- .../core/deeplink/JamDeepLinkService.kt | 50 - .../dev/krtirtho/spotube/core/di/Modules.kt | 13 +- .../spotube/core/jam/JamInviteCodec.kt | 105 - .../krtirtho/spotube/core/jam/JamProtocol.kt | 105 +- .../spotube/core/jam/JamRoomClient.kt | 323 +++ .../krtirtho/spotube/core/jam/JamRoomCode.kt | 47 + .../spotube/core/jam/JamRoomService.kt | 475 ++++ .../spotube/core/jam/JamSessionService.kt | 587 ----- .../spotube/core/jam/QueueSyncManager.kt | 316 --- .../core/remote/RemotePlaybackController.kt | 37 +- .../modules/devices/PlayDestinationPicker.kt | 8 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 537 +--- .../spotube/modules/jam/JamViewModel.kt | 428 +--- .../modules/settings/SettingsModels.kt | 21 +- .../modules/settings/SettingsScreen.kt | 6 + .../modules/settings/sections/JamSection.kt | 208 ++ .../spotube/modules/shell/AppShell.kt | 18 + .../PlayerQueueContentViewModel.kt | 7 +- composeApp/src/commonMain/rust/lib.rs | 2 - composeApp/src/commonMain/rust/webrtc_p2p.rs | 340 --- gradle/libs.versions.toml | 6 + 28 files changed, 1361 insertions(+), 4485 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt delete mode 100644 composeApp/src/commonMain/rust/webrtc_p2p.rs diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 1897105f..d8199d57 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -8,41 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "anyhow" version = "1.0.103" @@ -91,97 +56,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive 0.5.1", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive 0.6.0", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-compat" version = "0.2.5" @@ -195,41 +69,12 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "basic-toml" version = "0.1.10" @@ -239,74 +84,12 @@ dependencies = [ "serde", ] -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytecheck" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "rancor", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - [[package]] name = "byteorder" version = "1.5.0" @@ -348,38 +131,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] -name = "cc" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "ccm" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" -dependencies = [ - "aead", - "cipher", - "ctr", - "subtle", + "thiserror", ] [[package]] @@ -388,130 +140,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] - [[package]] name = "compose-app" version = "0.1.0" dependencies = [ - "async-trait", - "bytes", "discord-rich-presence", "lofty", "log", "parking_lot", - "rtc", - "thiserror 2.0.18", + "thiserror", "tokio", "uniffi", - "webrtc", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", ] [[package]] @@ -523,133 +162,12 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -dependencies = [ - "asn1-rs 0.6.2", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs 0.7.2", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - [[package]] name = "discord-rich-presence" version = "1.1.0" @@ -661,54 +179,8 @@ dependencies = [ "serde_derive", "serde_json", "serde_repr", - "thiserror 2.0.18", - "uuid 0.8.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", + "thiserror", + "uuid", ] [[package]] @@ -724,27 +196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", + "windows-sys", ] [[package]] @@ -753,28 +205,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - [[package]] name = "flate2" version = "1.1.9" @@ -785,15 +215,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fs-err" version = "2.11.0" @@ -803,105 +224,18 @@ dependencies = [ "autocfg", ] -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -922,17 +256,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", ] [[package]] @@ -952,17 +275,6 @@ dependencies = [ "scroll", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -975,134 +287,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1113,39 +297,12 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.186" @@ -1158,12 +315,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - [[package]] name = "lock_api" version = "0.4.14" @@ -1205,31 +356,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1246,50 +378,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "munge" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" -dependencies = [ - "munge_macro", -] - -[[package]] -name = "munge_macro" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nom" version = "7.1.3" @@ -1300,40 +388,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "ogg_pager" version = "0.7.2" @@ -1343,66 +397,12 @@ dependencies = [ "byteorder", ] -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs 0.6.2", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs 0.7.2", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1432,25 +432,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1463,69 +444,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1535,39 +459,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ptr_meta" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "quinn-udp" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" -dependencies = [ - "cfg_aliases", - "libc", - "log", - "socket2", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.46" @@ -1583,401 +474,13 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rancor" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" -dependencies = [ - "ptr_meta", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20 0.10.1", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rcgen" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser 0.18.1", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "rend" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" -dependencies = [ - "bytecheck", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rkyv" -version = "0.8.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" -dependencies = [ - "bytecheck", - "bytes", - "hashbrown", - "indexmap", - "munge", - "ptr_meta", - "rancor", - "rend", - "rkyv_derive", - "tinyvec", - "uuid 1.24.1", -] - -[[package]] -name = "rkyv_derive" -version = "0.8.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "rtc" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9005c36795ad076abd36db3ea9ae0275a60395944647d58c1f2bc3e118dddba" -dependencies = [ - "bytes", - "hex", - "log", - "rand", - "rcgen", - "ring", - "rtc-datachannel", - "rtc-dtls", - "rtc-ice", - "rtc-interceptor", - "rtc-mdns", - "rtc-media", - "rtc-rtcp", - "rtc-rtp", - "rtc-sctp", - "rtc-sdp", - "rtc-shared", - "rtc-srtp", - "rtc-stun", - "rtc-turn", - "rustls", - "sansio", - "serde", - "serde_json", - "sha2", - "unicase", - "url", -] - -[[package]] -name = "rtc-datachannel" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b61c7b8e9892094cba8a6c7fb8120ae863d32d432b00e560177b3d7ae6d2c6" -dependencies = [ - "bytes", - "log", - "rtc-sctp", - "rtc-shared", - "sansio", -] - -[[package]] -name = "rtc-dtls" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c737b1dd17a0ff63884a2f466b9588f0cffadfce5a724c0c368764d7c27f9c74" -dependencies = [ - "aes", - "bytecheck", - "byteorder", - "bytes", - "cbc", - "ccm", - "chacha20poly1305", - "der-parser 9.0.0", - "hmac", - "log", - "p256", - "p384", - "rand", - "rand_core 0.6.4", - "rcgen", - "ring", - "rkyv", - "rtc-shared", - "rustls", - "sec1", - "sha1", - "sha2", - "subtle", - "x25519-dalek", - "x509-parser 0.16.0", -] - -[[package]] -name = "rtc-ice" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c06eeabd250a7693e1e8b28222b78c4a81a7c6ca7fe3cb99bbdff2f6c0ff0ab" -dependencies = [ - "bytes", - "crc", - "log", - "rand", - "rtc-mdns", - "rtc-shared", - "rtc-stun", - "sansio", - "serde", - "url", - "uuid 1.24.1", -] - -[[package]] -name = "rtc-interceptor" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ec2776ab86c0c03c3de8ec742aa13ccc3b2c82317590d0624f251bd37eae3b9" -dependencies = [ - "log", - "rand", - "rtc-interceptor-derive", - "rtc-rtcp", - "rtc-rtp", - "rtc-shared", - "sansio", -] - -[[package]] -name = "rtc-interceptor-derive" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc31da3875839bc18e00997354a582e6decacdb23d39f8c004fc4fe2fd3fcd8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "rtc-mdns" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cd53120b83321c8b8310cce9512c685d2d6356acef34647a99ea420e6f2249" -dependencies = [ - "bytes", - "log", - "rtc-shared", - "sansio", - "socket2", -] - -[[package]] -name = "rtc-media" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0411e44fcdc1363487ae0c6a4612ec908b9b09175d05499c5de0b3c81f0b5e5" -dependencies = [ - "byteorder", - "bytes", - "rand", - "rtc-rtp", - "rtc-shared", - "thiserror 2.0.18", -] - -[[package]] -name = "rtc-rtcp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fceaadac17b114140368dd68478d186ccfc293970f79cb8dd28f94dbbc0300be" -dependencies = [ - "bytes", - "rtc-shared", -] - -[[package]] -name = "rtc-rtp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "babb45ac2263340e2bb412d1921aa07ca441ae9b5589477221d1a50d98750ba3" -dependencies = [ - "bytes", - "memchr", - "rand", - "rtc-shared", - "serde", -] - -[[package]] -name = "rtc-sctp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513391b524b88b041277faba617e0531ad726729861ea8933015393fd68ab05" -dependencies = [ - "bytes", - "crc32c", - "log", - "rand", - "rtc-shared", - "rustc-hash", - "slab", - "thiserror 2.0.18", -] - -[[package]] -name = "rtc-sdp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d136a422965289e3d32d8e110789d8fa2ac669a4474a4084fa1eeac32758ee" -dependencies = [ - "rand", - "rtc-shared", - "url", -] - -[[package]] -name = "rtc-shared" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce81c72adbce3a2f2ad8e2b97674d2482be83cb3bfd9a153341b04b1a87ca24" -dependencies = [ - "aes", - "aes-gcm", - "bitflags 1.3.2", - "bytes", - "nix", - "p256", - "rand", - "rcgen", - "sec1", - "serde", - "substring", - "thiserror 2.0.18", - "url", - "winapi", -] - -[[package]] -name = "rtc-srtp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b880d5f6d436b3215c7bcfd3fb5ffe2a752fea0236440ccd98b6bf1c3561df8" -dependencies = [ - "aes", - "byteorder", - "bytes", - "ctr", - "hmac", - "ring", - "rtc-rtcp", - "rtc-rtp", - "rtc-shared", - "sha1", - "subtle", -] - -[[package]] -name = "rtc-stun" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06b19c36a200df6071b92a25583a19ab0f9c6d5ad036d8c3ff5d392802cc926e" -dependencies = [ - "base64", - "bytes", - "crc", - "lazy_static", - "md-5", - "rand", - "ring", - "rtc-shared", - "sansio", - "subtle", - "url", -] - -[[package]] -name = "rtc-turn" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf0b5fbb94085c86be7277c38ae8f1c21ec75ab47ba975be0984409732020dd" -dependencies = [ - "bytes", - "log", - "rtc-shared", - "rtc-stun", - "sansio", + "bitflags", ] [[package]] @@ -1986,83 +489,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "sansio" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" - [[package]] name = "scopeguard" version = "1.2.0" @@ -2089,20 +528,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "semver" version = "1.0.28" @@ -2167,68 +592,18 @@ dependencies = [ "syn 3.0.2", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.15.2" @@ -2241,53 +616,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "substring" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" -dependencies = [ - "autocfg", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.119" @@ -2310,17 +644,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2331,7 +654,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2343,33 +666,13 @@ dependencies = [ "smawk", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -2383,73 +686,14 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "libc", - "mio", "pin-project-lite", - "socket2", "tokio-macros", - "windows-sys 0.61.2", ] [[package]] @@ -2472,18 +716,6 @@ dependencies = [ "serde", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2610,40 +842,6 @@ dependencies = [ "weedle2", ] -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "uuid" version = "0.8.2" @@ -2653,92 +851,12 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "uuid" -version = "1.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "webrtc" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3daa8f2f6366331ae3275a6c02a855c6fb3faa1d16960498d7daaf61c96e76bd" -dependencies = [ - "async-broadcast", - "async-channel", - "async-trait", - "bytes", - "event-listener", - "futures", - "log", - "quinn-udp", - "rtc", - "tokio", -] - [[package]] name = "weedle2" version = "5.0.0" @@ -2748,43 +866,12 @@ dependencies = [ "nom", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2794,70 +881,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "winnow" version = "0.7.15" @@ -2867,166 +890,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", - "zeroize", -] - -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs 0.6.2", - "data-encoding", - "der-parser 9.0.0", - "lazy_static", - "nom", - "oid-registry 0.7.1", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs 0.7.2", - "data-encoding", - "der-parser 10.0.0", - "lazy_static", - "nom", - "oid-registry 0.8.1", - "ring", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 900cd117..8a06d501 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -10,10 +10,6 @@ discord-rich-presence = "1.1.0" thiserror = "2.0" parking_lot = "0.12" log = "0.4" -webrtc = "0.20.4" -rtc = "0.20.4" -async-trait = "0.1" -bytes = "1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } [lib] diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 54122dfd..cdca27ed 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -166,6 +166,12 @@ kotlin { // DLNA implementation(libs.dns.sd.kt) + + // mqtt client for jam-session + implementation(libs.mqtt.client) + implementation(libs.mqtt.x.models) + implementation(libs.mqtt.buffer) + implementation(libs.mqtt.buffer.codec) } } commonTest.dependencies { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 3245b681..9e832592 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -55,17 +55,6 @@ - - - - - - - - - Send and receive data over the internet WebView Display web content inside the app + Group Jam + MQTT Broker + Broker host + %1$s:%2$d + Broker port + Use TLS + Username (optional) + Password (optional) + Client ID prefix + Test connection + Testing… + Connected in %1$d ms + Failed: %1$s + Placeholder broker — configure your own to self-host diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 92aa8241..ee165e9b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -33,8 +33,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay -import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES @@ -106,12 +104,6 @@ fun App( val settingsRepository: SettingsRepository = koinInject() val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) - val jamDeepLinks: JamDeepLinkService = koinInject() - DisposableEffect(Unit) { - ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) } - onDispose { ExternalUriHandler.listener = null } - } - val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt index b2864360..1562a2e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.Serializable @Serializable sealed interface QueueEntry { val url: String + val addedBy: String @Serializable @SerialName("streaming") @@ -32,6 +33,7 @@ sealed interface QueueEntry { val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + override val addedBy: String = "", ) : QueueEntry @Serializable @@ -42,7 +44,8 @@ sealed interface QueueEntry { val duration: Long, val album: String?, val coverBytes: ByteArray?, - override val url: String + override val url: String, + override val addedBy: String = "", ) : QueueEntry { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -56,6 +59,7 @@ sealed interface QueueEntry { if (album != other.album) return false if (!coverBytes.contentEquals(other.coverBytes)) return false if (url != other.url) return false + if (addedBy != other.addedBy) return false return true } @@ -67,6 +71,7 @@ sealed interface QueueEntry { result = 31 * result + (album?.hashCode() ?: 0) result = 31 * result + (coverBytes?.contentHashCode() ?: 0) result = 31 * result + url.hashCode() + result = 31 * result + addedBy.hashCode() return result } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt deleted file mode 100644 index 32e529db..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.deeplink - -import dev.krtirtho.spotube.core.jam.JamInviteCodec -import dev.krtirtho.spotube.core.jam.JamInviteLink -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI, - * and navigates to [Routes.Jam] so the user lands where the link is handled. - */ -class JamDeepLinkService( - private val navigationCommands: NavigationCommands, -) { - private val _pendingLink = MutableStateFlow(null) - val pendingLink: StateFlow = _pendingLink.asStateFlow() - - fun handleUri(uri: String) { - val link = JamInviteCodec.parse(uri) ?: return - _pendingLink.value = link - navigationCommands.navigateTo(Routes.Jam) - } - - /** Consumes the currently pending link (if any). */ - fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null } - - fun clear() { - _pendingLink.value = null - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 8bf06e4f..a130729f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -23,10 +23,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomClient +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.navigationModule import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler @@ -185,12 +185,9 @@ val sharedModules = module { viewModelOf(::RemoteControlViewModel) viewModel { JamViewModel( - jamSession = get(), - deepLinks = get(), + jamRoomService = get(), shareService = get(), settingsProvider = get(), - audioPlayer = get(), - audioPlayerQueue = get(), ) } @@ -239,8 +236,8 @@ val sharedModules = module { createdAtStart() } single { RemotePlaybackController(get(), get(), get(), get(), get()) } - single { JamSessionService(get(), get(), get()) } - singleOf(::JamDeepLinkService) + singleOf(::JamRoomClient) + single { JamRoomService(get(), get(), get(), get()) } singleOf(::AudioPlayerQueueRepository) { bind() } single { DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt deleted file mode 100644 index 7ecb8ff9..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.jam - -import io.ktor.http.decodeURLQueryComponent -import io.ktor.http.encodeURLParameter - -/** - * SDP payloads exchanged between jam peers are wrapped into `spotube://` deep links - * so they can be shared through any messaging medium. The SDP blob is percent-encoded - * as a query parameter. - * - * Host invite : `spotube://jam/invite?name=&sdp=` - * Guest answer : `spotube://jam/answer?name=&sdp=` - */ -sealed interface JamInviteLink { - val peerName: String - val sdp: String - - data class HostInvite( - override val peerName: String, - override val sdp: String, - ) : JamInviteLink - - data class GuestAnswer( - override val peerName: String, - override val sdp: String, - ) : JamInviteLink -} - -object JamInviteCodec { - const val SCHEME = "spotube" - const val INVITE_PATH = "jam/invite" - const val ANSWER_PATH = "jam/answer" - - fun buildHostInvite(hostName: String, offerSdp: String): String = - buildLink(INVITE_PATH, hostName, offerSdp) - - fun buildGuestAnswer(guestName: String, answerSdp: String): String = - buildLink(ANSWER_PATH, guestName, answerSdp) - - private fun buildLink(path: String, peerName: String, sdp: String): String = - "$SCHEME://$path?name=${peerName.encodeURLParameter()}" + - "&sdp=${sdp.encodeURLParameter()}" - - /** - * Parses a `spotude://jam/...` link. Returns null for foreign or malformed URIs. - * Parsing is done manually — generic URI parsers normalize unknown schemes in - * ways that mangle percent-encoded multi-line payloads. - */ - fun parse(rawUri: String): JamInviteLink? { - val uri = rawUri.trim() - if (!uri.startsWith("$SCHEME://", ignoreCase = true)) return null - - val withoutScheme = uri.substring(SCHEME.length + 3) - val queryStart = withoutScheme.indexOf('?') - if (queryStart < 0) return null - - val path = withoutScheme.take(queryStart).trim('/').lowercase() - val params = withoutScheme.substring(queryStart + 1) - .split('&') - .mapNotNull { pair -> - val separator = pair.indexOf('=') - if (separator <= 0) return@mapNotNull null - pair.take(separator) to pair.substring(separator + 1) - } - .toMap() - - val sdp = params["sdp"]?.decodeURLQueryComponent()?.takeIf { it.isNotBlank() } - ?: return null - val peerName = params["name"]?.decodeURLQueryComponent().orEmpty() - - return when (path) { - INVITE_PATH -> JamInviteLink.HostInvite(peerName, sdp) - ANSWER_PATH -> JamInviteLink.GuestAnswer(peerName, sdp) - else -> null - } - } - - /** - * Extracts an SDP payload from user input which may either be a full - * `spotube://` deep link or a raw SDP body pasted by hand. - */ - fun extractSdp(rawInput: String): String? { - val input = rawInput.trim() - parse(input)?.let { return it.sdp } - // Heuristic for raw SDP: first line is the session description header - return if (input.startsWith("v=", ignoreCase = false)) input else null - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt index 01fafa94..ac9e35d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -18,61 +18,46 @@ package dev.krtirtho.spotube.core.jam import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.QueueEntry import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * Jam messages exchanged over MQTT. + * + * - `state` topic: [QueueState] (retained, host -> everyone) + * - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist] + * (anyone -> host, except Kick which is host -> guest) + * - `presence/{clientId}` topic: [JamPresence] (retained, one per participant) + */ @Serializable sealed class JamMessage { - @Serializable - @SerialName("hello") - data class Hello( - val displayName: String, - val deviceId: String, - ) : JamMessage() - - @Serializable - @SerialName("welcome") - data class Welcome( - val hostName: String, - val participantId: String, - ) : JamMessage() - @Serializable @SerialName("queueState") data class QueueState( val items: List, val currentIndex: Int, - val isPlaying: Boolean, - val positionMs: Long, + val shuffleEnabled: Boolean = false, ) : JamMessage() @Serializable @SerialName("playbackCommand") - data class PlaybackCommand( - val command: PlaybackCmd, - ) : JamMessage() + data class PlaybackCommand(val command: PlaybackCmd) : JamMessage() @Serializable @SerialName("suggestTrack") - data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage() - - @Serializable - @SerialName("suggestPlaylist") - data class SuggestPlaylist(val tracks: List) : JamMessage() - - @Serializable - @SerialName("chat") - data class Chat( - val fromName: String, - val text: String, + data class SuggestTrack( + val mediaItem: JamMediaItem, + val addedBy: String = "", ) : JamMessage() @Serializable - @SerialName("participantList") - data class ParticipantList(val participants: List) : JamMessage() + @SerialName("suggestPlaylist") + data class SuggestPlaylist( + val tracks: List, + val addedBy: String = "", + ) : JamMessage() @Serializable @SerialName("kick") @@ -86,24 +71,12 @@ sealed class JamMessage { data class Leave(val reason: String = "user_left") : JamMessage() } +/** + * Playback commands. Only queue navigation is global — play/pause, seek, + * volume, shuffle and loop are local to each participant. + */ @Serializable sealed class PlaybackCmd { - @Serializable - @SerialName("play") - data object Play : PlaybackCmd() - - @Serializable - @SerialName("pause") - data object Pause : PlaybackCmd() - - @Serializable - @SerialName("toggle") - data object Toggle : PlaybackCmd() - - @Serializable - @SerialName("seek") - data class Seek(val positionMs: Long) : PlaybackCmd() - @Serializable @SerialName("skipNext") data object SkipNext : PlaybackCmd() @@ -112,23 +85,20 @@ sealed class PlaybackCmd { @SerialName("skipPrevious") data object SkipPrevious : PlaybackCmd() - @Serializable - @SerialName("setVolume") - data class SetVolume(val volume: Float) : PlaybackCmd() - - @Serializable - @SerialName("setLoop") - data class SetLoop(val loop: String) : PlaybackCmd() - - @Serializable - @SerialName("setShuffle") - data class SetShuffle(val enabled: Boolean) : PlaybackCmd() - @Serializable @SerialName("jumpTo") data class JumpTo(val index: Int) : PlaybackCmd() } +/** Retained per-participant presence entry (with an MQTT Last Will for leave). */ +@Serializable +data class JamPresence( + val clientId: String, + val displayName: String, + val isHost: Boolean, + val left: Boolean = false, +) + @Serializable data class JamMediaItem( val url: String, @@ -139,6 +109,7 @@ data class JamMediaItem( val durationMs: Long, val coverUrl: String, val protocol: String, + val addedBy: String = "", ) { companion object { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { @@ -153,6 +124,7 @@ data class JamMediaItem( ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url .orEmpty(), protocol = entry.protocol.name, + addedBy = entry.addedBy, ) is QueueEntry.LocalTrack -> JamMediaItem( @@ -164,6 +136,7 @@ data class JamMediaItem( durationMs = entry.duration, coverUrl = "", protocol = "PROGRESSIVE", + addedBy = entry.addedBy, ) } @@ -214,14 +187,4 @@ data class JamParticipant( enum class JamRole { Host, Guest, -} - -object JamLoopMapping { - fun toString(state: LoopState): String = state.name.lowercase() - fun fromString(value: String): LoopState = when (value.lowercase()) { - "none" -> LoopState.NONE - "one" -> LoopState.ONE - "all" -> LoopState.ALL - else -> LoopState.NONE - } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt new file mode 100644 index 00000000..83531f19 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt @@ -0,0 +1,323 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.jam + +import co.touchlab.kermit.Logger +import com.ditchoom.buffer.Charset +import com.ditchoom.buffer.codec.asReadBuffer +import com.ditchoom.buffer.toReadBuffer +import com.ditchoom.mqtt.client.ConnectionState +import com.ditchoom.mqtt.client.MqttClient +import com.ditchoom.mqtt.connection.MqttConnectionOptions +import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec +import com.ditchoom.mqtt.controlpacket.QualityOfService +import com.ditchoom.mqtt.controlpacket.TopicName +import com.ditchoom.mqtt.controlpacket.WillConfig +import com.ditchoom.mqtt5.controlpacket.ConnectionRequest +import dev.krtirtho.spotube.modules.settings.JamBroker +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent + +/** + * Thin facade over the Ditchoom MQTT 5 client for one jam room. + * + * Topics (room code `C`): + * - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone) + * - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest) + * - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will + * (`left = true`) so a dropped client disappears from the room automatically. + * + * The library keeps the connection alive (auto-reconnect + backoff); this class only + * re-establishes the room subscription and re-publishes presence after a reconnect. + */ +class JamRoomClient : KoinComponent { + private val log = Logger.withTag("JamRoomClient") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private var client: MqttClient? = null + + private var roomCode: String? = null + private var localPresence: JamPresence? = null + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _state = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + val state: SharedFlow = _state.asSharedFlow() + + private val _commands = MutableSharedFlow(extraBufferCapacity = 32) + val commands: SharedFlow = _commands.asSharedFlow() + + private val _presence = MutableStateFlow>(emptyMap()) + val presence: StateFlow> = _presence.asStateFlow() + + // ---------- Public API ---------- + + /** One-off connection check used by the settings screen. Returns latency description. */ + suspend fun testConnection(broker: JamBroker): Result { + if (broker.host.isBlank()) return Result.failure(IllegalArgumentException("Broker host is empty")) + val started = kotlin.time.TimeSource.Monotonic.markNow() + return runCatching { + val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test") + try { + withTimeout(broker.connectionTimeoutSeconds.seconds) { + client.awaitConnectivity() + } + "Connected in ${started.elapsedNow().inWholeMilliseconds} ms" + } finally { + runCatching { client.shutdown(sendDisconnect = true, drain = false) } + } + } + } + + /** Connects to [code] on [broker] and starts routing room messages. */ + suspend fun connect( + broker: JamBroker, + code: String, + clientId: String, + displayName: String, + isHost: Boolean, + ): Result { + disconnect() + if (broker.host.isBlank()) { + return Result.failure(IllegalArgumentException("No jam broker configured")) + } + return runCatching { + roomCode = code + localPresence = JamPresence( + clientId = clientId, + displayName = displayName, + isHost = isHost, + left = false, + ) + + val mqtt = startClient(broker, clientId) + client = mqtt + withTimeout(broker.connectionTimeoutSeconds.seconds) { + mqtt.awaitConnectivity() + } + _connectionError.value = null + log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" } + + mqtt.connectionState + .onEach { onConnectionStateChanged(it) } + .launchIn(scope) + Unit + }.onFailure { e -> + log.w(e) { "Failed to connect to jam broker" } + _connectionError.value = e.message ?: "Connection failed" + _isConnected.value = false + runCatching { client?.shutdown(sendDisconnect = true, drain = false) } + client = null + } + } + + suspend fun publishState(state: JamMessage.QueueState) { + val code = roomCode ?: return + publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true) + } + + suspend fun publishCommand(message: JamMessage) { + val code = roomCode ?: return + publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false) + } + + /** Publishes our own (retained) presence. Re-published after every reconnect. */ + suspend fun publishPresence() { + val code = roomCode ?: return + val presence = localPresence ?: return + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence), + retain = true, + ) + } + + /** Marks us as the host in presence (host takeover). */ + suspend fun claimHost() { + val presence = localPresence ?: return + localPresence = presence.copy(isHost = true) + publishPresence() + } + + /** Graceful leave: publish `left = true` before disconnecting. */ + suspend fun leavePresence() { + val presence = localPresence ?: return + val code = roomCode ?: return + runCatching { + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), + retain = true, + ) + } + } + + suspend fun disconnect() { + roomCode = null + localPresence = null + _isConnected.value = false + _presence.value = emptyMap() + val current = client + client = null + runCatching { current?.shutdown(sendDisconnect = true, drain = false) } + } + + // ---------- Internals ---------- + + private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient { + val connection = MqttConnectionOptions.SocketConnection( + host = broker.host, + port = broker.port, + tlsEnabled = broker.useTls, + connectionTimeout = broker.connectionTimeoutSeconds.seconds, + ) + val code = roomCode ?: "unset" + val will = WillConfig.Enabled( + topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), + payload = json.encodeToString( + JamPresence.serializer(), + JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true), + ).toReadBuffer(Charset.UTF8), + qos = QualityOfService.AT_LEAST_ONCE, + retain = true, + ) + val request = ConnectionRequest( + clientId = clientId, + keepAliveSeconds = broker.keepAliveSeconds, + cleanStart = true, + userName = broker.username, + password = broker.password, + will = will, + ) + val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) + val brokerRef = persistence.addBroker(connection, request) + return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) + } + + private fun onConnectionStateChanged(state: ConnectionState) { + when (state) { + is ConnectionState.Connected -> { + _isConnected.value = true + _connectionError.value = null + scope.launch { + // Every connection (initial + reconnects) must re-establish the + // broker-side subscription (clean session) and re-publish our + // retained presence to clear any Last Will. Re-subscribing with + // the same filter replaces the previous dispatcher handler. + subscribeRoom() + publishPresence() + } + } + + ConnectionState.Disconnected, ConnectionState.Handshaking -> { + _isConnected.value = false + } + + else -> { + _isConnected.value = false + _connectionError.value = "Connection lost" + } + } + } + + private suspend fun subscribeRoom() { + val mqtt = client ?: return + val code = roomCode ?: return + val operation = mqtt.subscribe( + roomFilter(code), + OpaquePublishPayloadCodec, + QualityOfService.AT_LEAST_ONCE, + ) { publish, payload -> + route(publish.topic.toString(), payload) + } + runCatching { operation.subAck.await() } + .onFailure { log.w(it) { "Subscribe ack failed for room $code" } } + } + + private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) { + val text = runCatching { + val buffer = payload.handle.asReadBuffer() + buffer.readString(buffer.remaining(), Charset.UTF8) + }.getOrElse { e -> + log.w(e) { "Failed to read jam payload on $topic" } + return + } + + runCatching { + when { + topic.endsWith("/state") -> { + _state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text)) + } + + topic.endsWith("/cmd") -> { + _commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text)) + } + + topic.contains("/presence/") -> { + val presence = json.decodeFromString(JamPresence.serializer(), text) + _presence.value = _presence.value + (presence.clientId to presence) + } + } + }.onFailure { e -> + log.w(e) { "Failed to decode jam message on $topic: $text" } + } + } + + private suspend fun publishJson(topic: String, payload: String, retain: Boolean) { + val mqtt = client ?: return + runCatching { + mqtt.publish( + topicName = topic, + qos = QualityOfService.AT_LEAST_ONCE, + payload = payload.toReadBuffer(Charset.UTF8), + retain = retain, + ) + }.onFailure { e -> + log.w(e) { "Failed to publish to $topic" } + } + } + + private fun stateTopic(code: String) = "spotube/jam/$code/state" + private fun cmdTopic(code: String) = "spotube/jam/$code/cmd" + private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId" + private fun roomFilter(code: String) = "spotube/jam/$code/#" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt new file mode 100644 index 00000000..0a1b891f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt @@ -0,0 +1,47 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.jam + +import kotlin.random.Random + +/** + * Six-character room codes shared verbally / by text. Codes are opaque keys used + * to namespace the MQTT topics of a jam room — they carry no connection details. + * + * The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to + * read aloud and retype. + */ +object JamRoomCode { + const val LENGTH = 6 + private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + fun generate(): String = buildString(LENGTH) { + repeat(LENGTH) { + append(ALPHABET[Random.nextInt(ALPHABET.length)]) + } + } + + /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ + fun normalize(input: String): String = input + .uppercase() + .filter { it.isLetterOrDigit() } + .take(LENGTH) + + fun isValid(code: String): Boolean = + code.length == LENGTH && code.all { it in ALPHABET } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt new file mode 100644 index 00000000..8107e1cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -0,0 +1,475 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.jam + +import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * A jam session over MQTT (star topology, host-authoritative queue). + * + * Sync rules: + * - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo] + * from anyone are applied by the host, then broadcast via the retained state topic. + * - Play/pause, seek, volume and loop are local to each device — never broadcast. + * When the queue moves on, a paused participant stays paused; a playing one + * keeps playing the new current item. + * - Shuffle is host-only; guests mirror the host's shuffle setting. + * - Guests can only add to the queue (suggest); the host applies suggestions. + * - If the host leaves, the participant with the lowest client id takes over. + */ +class JamRoomService( + private val jamClient: JamRoomClient, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val settingsRepository: SettingsRepository, +) { + private val log = Logger.withTag("JamRoomService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _role = MutableStateFlow(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _roomCode = MutableStateFlow(null) + val roomCode: StateFlow = _roomCode.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _shuffleEnabled = MutableStateFlow(false) + val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() + + private var localClientId: String = "" + private var localDisplayName: String = "" + private var hostBroadcastJob: Job? = null + + /** Guest side: last queue snapshot applied to the local player. */ + private var lastAppliedItems: List = emptyList() + private var lastAppliedIndex = -1 + + /** Host side: client ids banned for this session. */ + private val bannedClientIds = mutableSetOf() + + private var leaving = false + + init { + jamClient.isConnected + .onEach { _isConnected.value = it } + .launchIn(scope) + jamClient.connectionError + .onEach { _connectionError.value = it } + .launchIn(scope) + jamClient.state + .onEach { onRemoteState(it) } + .launchIn(scope) + jamClient.commands + .onEach { onCommand(it) } + .launchIn(scope) + jamClient.presence + .onEach { onPresence(it) } + .launchIn(scope) + } + + // ---------- Session lifecycle ---------- + + suspend fun createRoom(): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException("No jam broker configured")) + } + val code = JamRoomCode.generate() + localClientId = newClientId(broker) + val name = participantName("Host") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = code, + clientId = localClientId, + displayName = name, + isHost = true, + ).map { + _role.value = JamRole.Host + _roomCode.value = code + _participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + leaving = false + startHostBroadcast() + persistLastCode(code) + code + } + } + + suspend fun joinRoom(code: String): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException("No jam broker configured")) + } + val normalized = JamRoomCode.normalize(code) + if (!JamRoomCode.isValid(normalized)) { + return Result.failure(IllegalArgumentException("Invalid room code")) + } + localClientId = newClientId(broker) + val name = participantName("Guest") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = normalized, + clientId = localClientId, + displayName = name, + isHost = false, + ).map { + _role.value = JamRole.Guest + _roomCode.value = normalized + _participants.value = emptyList() + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + leaving = false + persistLastCode(normalized) + } + } + + suspend fun leaveRoom() { + leaving = true + stopHostBroadcast() + runCatching { jamClient.leavePresence() } + jamClient.disconnect() + _role.value = null + _roomCode.value = null + _participants.value = emptyList() + _shuffleEnabled.value = false + _isConnected.value = false + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + } + + // ---------- Controls (called from the UI) ---------- + + fun skipNext() { + publishCommand(PlaybackCmd.SkipNext) + } + + fun skipPrevious() { + publishCommand(PlaybackCmd.SkipPrevious) + } + + fun jumpTo(index: Int) { + publishCommand(PlaybackCmd.JumpTo(index)) + } + + /** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */ + fun toggleShuffle() { + if (_role.value != JamRole.Host) return + scope.launch { + runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) } + } + } + + suspend fun suggestTrack(track: MetadataTrack) { + jamClient.publishCommand( + JamMessage.SuggestTrack( + mediaItem = JamMediaItem.fromTrack(track), + addedBy = localDisplayName, + ) + ) + } + + suspend fun suggestPlaylist(tracks: List) { + if (tracks.isEmpty()) return + jamClient.publishCommand( + JamMessage.SuggestPlaylist( + tracks = tracks.map(JamMediaItem::fromTrack), + addedBy = localDisplayName, + ) + ) + } + + suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { + if (_role.value != JamRole.Host) return + jamClient.publishCommand(JamMessage.Kick(participantId, reason)) + } + + suspend fun banParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + bannedClientIds += participantId + kickParticipant(participantId, "banned by host") + } + + // ---------- Host: broadcast ---------- + + private fun startHostBroadcast() { + if (hostBroadcastJob?.isActive == true) return + hostBroadcastJob = scope.launch { + combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.shuffleModeFlow, + ) { queue, current, shuffle -> Triple(queue, current, shuffle) } + .onEach { (queue, current, shuffle) -> + if (_role.value != JamRole.Host) return@onEach + val index = if (current != null) { + queue.indexOfFirst { it.matchesEntry(current) } + } else { + -1 + } + _shuffleEnabled.value = shuffle + jamClient.publishState( + JamMessage.QueueState( + items = queue.map(JamMediaItem::fromQueueEntry), + currentIndex = index.coerceAtLeast(0), + shuffleEnabled = shuffle, + ) + ) + } + .launchIn(this) + } + } + + private fun stopHostBroadcast() { + hostBroadcastJob?.cancel() + hostBroadcastJob = null + } + + // ---------- Guest: apply remote state ---------- + + private suspend fun onRemoteState(state: JamMessage.QueueState) { + if (_role.value != JamRole.Guest) return + if (leaving) return + + _shuffleEnabled.value = state.shuffleEnabled + runCatching { audioPlayer.shuffle(state.shuffleEnabled) } + + val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING + + if (items != lastAppliedItems) { + lastAppliedItems = items + lastAppliedIndex = state.currentIndex + runCatching { + audioPlayerQueue.load( + entries = items.map { it.toQueueEntry() }, + autoPlay = wasPlaying, + startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), + ) + }.onFailure { log.w(it) { "Failed to apply jam queue" } } + return + } + + if (state.currentIndex != lastAppliedIndex) { + lastAppliedIndex = state.currentIndex + // Queue moved on: follow it, but keep this device's play/pause state. + runCatching { + audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) + }.onFailure { log.w(it) { "Failed to follow jam queue index" } } + } + } + + // ---------- Incoming commands ---------- + + private suspend fun onCommand(message: JamMessage) { + when (message) { + is JamMessage.PlaybackCommand -> { + if (_role.value != JamRole.Host) return + applyCommand(message.command) + } + + is JamMessage.SuggestTrack -> { + if (_role.value == JamRole.Host) acceptSuggestion(listOf(message.mediaItem)) + } + + is JamMessage.SuggestPlaylist -> { + if (_role.value == JamRole.Host) acceptSuggestion(message.tracks) + } + + is JamMessage.Kick -> { + if (_role.value == JamRole.Guest && message.participantId == localClientId) { + log.i { "Kicked from jam room: ${message.reason}" } + leaveRoom() + } + } + + else -> Unit + } + } + + private suspend fun applyCommand(command: PlaybackCmd) { + when (command) { + PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() } + PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() } + is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) } + } + } + + private suspend fun acceptSuggestion(items: List) { + if (items.isEmpty()) return + log.i { "Accepting ${items.size} suggested item(s) into the jam queue" } + runCatching { + audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() }) + } + } + + // ---------- Presence & host takeover ---------- + + private fun onPresence(all: Map) { + if (_role.value == null) return + val live = all.values.filter { !it.left } + _participants.value = live + .sortedBy { it.clientId } + .map { JamParticipant(it.clientId, it.displayName, it.isHost) } + + // Host-side: auto-kick banned participants that rejoin. + if (_role.value == JamRole.Host) { + live.filter { it.clientId in bannedClientIds }.forEach { banned -> + scope.launch { kickParticipant(banned.clientId, "banned by host") } + } + return + } + + val host = live.firstOrNull { it.isHost } + if (host != null) return + + // Host left: the lowest client id takes over (deterministic, clock-free). + val candidate = live.minByOrNull { it.clientId } ?: return + if (candidate.clientId != localClientId) return + + scope.launch { + delay(HOST_TAKEOVER_DELAY_MS) + val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost } + if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch + log.i { "Taking over as jam host (previous host left)" } + _role.value = JamRole.Host + jamClient.claimHost() + startHostBroadcast() + } + } + + // ---------- Helpers ---------- + + private fun publishCommand(command: PlaybackCmd) { + scope.launch { + jamClient.publishCommand(JamMessage.PlaybackCommand(command)) + } + } + + private fun participantName(fallbackPrefix: String): String { + val configured = settingsRepository.userSettings.value.jamParticipantName + return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" } + } + + private fun newClientId(broker: dev.krtirtho.spotube.modules.settings.JamBroker): String { + val suffix = buildString(6) { + val chars = "0123456789abcdef" + repeat(6) { append(chars[Random.nextInt(chars.length)]) } + } + return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix" + } + + private fun persistLastCode(code: String) { + scope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + if (settings.lastJamCode != code) { + settingsRepository.updateSettings(settings.copy(lastJamCode = code)) + } + } + } + } + + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { + trackId.isNotBlank() -> QueueEntry.StreamingTrack( + track = MetadataTrack( + id = trackId, + title = title, + durationMs = durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) + ), + album = null, + // Keep the cover art flowing to participants — the wire carries it + // as coverUrl, so mirror it back into the reconstructed thumbnails. + thumbnails = coverUrl.takeIf { it.isNotBlank() } + ?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) }, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = null, + ), + url = "", + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + addedBy = addedBy, + ) + + else -> QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = durationMs, + album = album.ifBlank { null }, + coverBytes = null, + url = url, + addedBy = addedBy, + ) + } + + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } + + companion object { + private const val HOST_TAKEOVER_DELAY_MS = 1_500L + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt deleted file mode 100644 index 30cf046d..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ /dev/null @@ -1,587 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.jam - -import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import org.koin.core.component.KoinComponent -import uniffi.compose_app.IceServerConfig -import uniffi.compose_app.WebrtcEventHandler -import uniffi.compose_app.WebrtcPeerConnection -import uniffi.compose_app.createWebrtcPeerConnection - -/** - * An invite generated by the host for one guest slot. The [sdp] offer is shared - * via a deep link; once the guest's answer comes back, [JamSessionService.acceptAnswer] - * completes the handshake for that slot. - */ -data class JamInvite( - val id: String, - val sdp: String, -) - -/** - * Owns the peer connections of a jam session (star topology: host relays state - * to all guests) and the hello/welcome handshake, participant bookkeeping and - * kick/ban. Playback & queue synchronization itself is delegated to - * [QueueSyncManager], which runs while a session is active. - */ -class JamSessionService( - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, - private val settingsProvider: SettingsProvider, -) : KoinComponent { - val logger by injectLogger() - private val log = Logger.withTag("JamSessionService") - - /** - * Playback/queue synchronization. Owned by this service (not a Koin bean) so - * the two don't form a circular dependency; it's started/stopped with the - * session lifecycle. - */ - private val queueSyncManager = QueueSyncManager( - audioPlayer = audioPlayer, - audioPlayerQueue = audioPlayerQueue, - jamSession = this, - ) - - private val json = Json { - ignoreUnknownKeys = true - classDiscriminator = "type" - encodeDefaults = true - } - - private val _role = MutableStateFlow(null) - val role: StateFlow = _role.asStateFlow() - - private val _participants = MutableStateFlow>(emptyList()) - val participants: StateFlow> = _participants.asStateFlow() - - private val _isActive = MutableStateFlow(false) - val isActive: StateFlow = _isActive.asStateFlow() - - private val _localParticipantId = MutableStateFlow(null) - val localParticipantId: StateFlow = _localParticipantId.asStateFlow() - - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected.asStateFlow() - - private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) - val incomingMessages = _incomingMessages.asSharedFlow() - - private val _incomingSuggestions = MutableSharedFlow(extraBufferCapacity = 32) - val incomingSuggestions = _incomingSuggestions.asSharedFlow() - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /** Host side: invites whose answers have not arrived yet. */ - private val pendingInvites = mutableMapOf() - - /** Host side: guests whose handshake completed. Keyed by invite id. */ - private val connectedGuests = mutableMapOf() - - /** Host side: guest device ids, used for bans. */ - private val guestDeviceIds = mutableMapOf() - - /** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */ - private val guestConnectionStates = mutableMapOf() - - /** Host side: device ids banned for this session. */ - private val bannedDeviceIds = mutableSetOf() - - /** Guest side: the single connection to the host. */ - private var hostConnection: WebrtcPeerConnection? = null - - private var hostDisplayName: String = "Host" - private var guestDisplayName: String = "Guest" - - suspend fun createSession(): String { - log.i { "Creating jam session" } - hostDisplayName = resolveParticipantName(defaultPrefix = "Host") - - _role.value = JamRole.Host - _localParticipantId.value = "host" - _participants.value = listOf( - JamParticipant( - id = "host", - displayName = hostDisplayName, - isHost = true, - ) - ) - _isActive.value = true - queueSyncManager.start() - - return generateInvite().sdp - } - - /** - * Generates a new invite (peer connection + SDP offer with bundled ICE candidates). - * Each invite admits exactly one guest. - */ - suspend fun generateInvite(): JamInvite { - if (_role.value != JamRole.Host) { - error("generateInvite can only be called by the host") - } - val inviteId = "guest-${randomShortId()}" - log.i { "Generating invite $inviteId" } - - val pc = createWebrtcPeerConnection( - iceServers = defaultIceServers(), - handler = guestEventHandler(inviteId), - ) - - pc.createDataChannel(CHANNEL_LABEL) - val offer = pc.createOffer() - pendingInvites[inviteId] = pc - return JamInvite(id = inviteId, sdp = offer) - } - - /** - * Completes a guest's handshake: applies their SDP answer to the peer connection - * created for [inviteId] and adds them to the participant list. - * - * When [inviteId] is null, the oldest still-pending invite is used — convenient - * when an answer deep link arrives out of band. - * - * @param answerSdp raw SDP answer body (not a deep link). - * @param peerName display name of the guest, taken from their answer link if available. - */ - suspend fun acceptAnswer(inviteId: String?, answerSdp: String, peerName: String): Boolean { - if (_role.value != JamRole.Host) { - log.w { "acceptAnswer ignored: not hosting" } - return false - } - val resolvedId = inviteId ?: pendingInvites.keys.firstOrNull() - if (resolvedId == null) { - log.w { "acceptAnswer: no pending invite" } - return false - } - val pc = pendingInvites.remove(resolvedId) - if (pc == null) { - log.w { "acceptAnswer: no pending invite '$resolvedId'" } - return false - } - runCatching { pc.setRemoteAnswer(answerSdp) } - .onFailure { e -> - log.w(e) { "Failed to apply answer for $inviteId" } - scope.launch { runCatching { pc.shutdown() } } - return false - } - connectedGuests[resolvedId] = pc - _participants.update { current -> - current + JamParticipant( - id = resolvedId, - displayName = peerName.ifBlank { "Guest-${resolvedId.takeLast(4)}" }, - isHost = false, - ) - } - log.i { "Guest $resolvedId ($peerName) joined" } - broadcastParticipantList() - return true - } - - suspend fun joinSession(offerSdp: String, hostName: String? = null): String { - log.i { "Joining jam session" } - guestDisplayName = resolveParticipantName(defaultPrefix = "Guest") - - val pc = createWebrtcPeerConnection( - iceServers = defaultIceServers(), - handler = eventHandler, - ) - - hostConnection = pc - _role.value = JamRole.Guest - _localParticipantId.value = null - _participants.value = listOf( - JamParticipant( - id = "host", - displayName = hostName?.ifBlank { null } ?: "Host", - isHost = true, - ) - ) - _isActive.value = true - queueSyncManager.start() - - // The data channel arrives in-band from the host's offer via on_data_channel; - // we only answer here. - pc.setRemoteOffer(offerSdp) - val answer = pc.createAnswer() - log.i { "Generated SDP answer (length=${answer.length})" } - return answer - } - - suspend fun sendMessage(message: JamMessage, guestId: String? = null) { - val payload = json.encodeToString(JamMessage.serializer(), message) - when (_role.value) { - JamRole.Host -> { - if (guestId != null) { - val pc = connectedGuests[guestId] ?: return - runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to guest $guestId" } - onSendFailure(guestId) - } - } else { - val dead = mutableListOf() - connectedGuests.forEach { (id, pc) -> - runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to guest $id" } - dead += id - } - } - dead.forEach { id -> onSendFailure(id) } - } - } - - JamRole.Guest -> { - runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to host" } - } - } - - null -> log.w { "sendMessage called while no session is active" } - } - } - - /** - * A send to a guest failed. If that guest's connection has already given up - * (failed/closed), drop them from the session; while the connection is merely - * "connecting" the channel may simply not be open yet, so keep them. - */ - private fun onSendFailure(guestId: String) { - val state = guestConnectionStates[guestId] - if (state == "failed" || state == "closed" || state == "disconnected") { - scope.launch { removeGuest(guestId) } - } - } - - suspend fun leave() { - log.i { "Leaving jam session" } - queueSyncManager.stop() - runCatching { sendMessage(JamMessage.Leave()) } - shutdownAll() - _role.value = null - _participants.value = emptyList() - _isActive.value = false - _isConnected.value = false - _localParticipantId.value = null - guestDeviceIds.clear() - guestConnectionStates.clear() - bannedDeviceIds.clear() - } - - suspend fun broadcastPlaybackCommand(command: PlaybackCmd) { - if (_role.value != JamRole.Host) return - sendMessage(JamMessage.PlaybackCommand(command)) - } - - suspend fun broadcastQueueState( - items: List, - currentIndex: Int, - isPlaying: Boolean, - positionMs: Long, - ) { - if (_role.value != JamRole.Host) return - sendMessage(JamMessage.QueueState(items, currentIndex, isPlaying, positionMs)) - } - - suspend fun suggestTrack(mediaItem: JamMediaItem) { - if (_role.value != JamRole.Guest) return - sendMessage(JamMessage.SuggestTrack(mediaItem)) - } - - suspend fun suggestPlaylist(tracks: List) { - if (_role.value != JamRole.Guest) return - sendMessage(JamMessage.SuggestPlaylist(tracks)) - } - - // ---------- Host moderation ---------- - - suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { - if (_role.value != JamRole.Host) return - log.i { "Kicking participant $participantId" } - sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId) - removeGuest(participantId) - } - - suspend fun banParticipant(participantId: String) { - if (_role.value != JamRole.Host) return - val deviceId = guestDeviceIds[participantId] - if (deviceId != null) { - bannedDeviceIds += deviceId - log.i { "Banning device $deviceId (participant $participantId)" } - } - kickParticipant(participantId, "banned by host") - } - - private suspend fun removeGuest(guestId: String) { - val pc = connectedGuests.remove(guestId) - runCatching { pc?.shutdown() } - guestDeviceIds.remove(guestId) - guestConnectionStates.remove(guestId) - _participants.update { current -> - current.filterNot { it.id == guestId } - } - broadcastParticipantList() - } - - private suspend fun broadcastParticipantList() { - if (_role.value != JamRole.Host) return - sendMessage(JamMessage.ParticipantList(_participants.value)) - } - - /** - * ICE servers for global peer-to-peer jam sessions: multiple STUN servers for - * NAT traversal plus a TURN relay for symmetric NATs and strict firewalls. - */ - private fun defaultIceServers(): List = listOf( - IceServerConfig( - urls = listOf( - "stun:stun.cloudflare.com:3478", - "stun:stun1.l.google.com:19302", - "stun:stun.l.google.com:19302", - ), - username = "", - credential = "", - ), - IceServerConfig( - urls = listOf("turn:openrelay.metered.ca:80"), - username = "openrelayproject", - credential = "openrelayproject", - ), - ) - - private suspend fun resolveParticipantName(defaultPrefix: String): String { - val settings = settingsProvider.settingsState.first() - return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" } - ?: "$defaultPrefix-${randomShortId()}" - } - - private fun localDeviceId(): String { - return settingsProvider.settingsState.value?.remoteControlDeviceId - ?: "device-${randomShortId()}" - } - - /** - * Per-guest handler so messages received on a guest's connection can be - * attributed back to that guest (needed for kick-on-leave and targeted sends). - */ - private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) { - log.i { "[$guestId] ICE candidate: $candidate" } - } - - override fun onIceGatheringStateChange(state: String) { - log.i { "[$guestId] ICE gathering state: $state" } - } - - override fun onConnectionStateChange(state: String) { - log.i { "[$guestId] Connection state: $state" } - guestConnectionStates[guestId] = state - if (state == "failed" || state == "closed") { - scope.launch { removeGuest(guestId) } - } - } - - override fun onDataChannelOpen(label: String) { - log.i { "[$guestId] Data channel '$label' open" } - _isConnected.value = true - } - - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(data, fromGuestId = guestId) - } - - override fun onDataChannelClose(label: String) { - log.i { "[$guestId] Data channel closed" } - if (_role.value == JamRole.Host) { - scope.launch { removeGuest(guestId) } - } - } - } - - private val eventHandler = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) { - log.i { "ICE candidate: $candidate" } - } - - override fun onIceGatheringStateChange(state: String) { - log.i { "ICE gathering state: $state" } - } - - override fun onConnectionStateChange(state: String) { - log.i { "Connection state: $state" } - if (state == "failed" || state == "closed") { - scope.launch { leave() } - } - } - - override fun onDataChannelOpen(label: String) { - log.i { "Data channel '$label' open" } - _isConnected.value = true - // Introduce ourselves so the host can fill in our name and hand us - // our participant id. - scope.launch { - sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId())) - } - } - - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(data, fromGuestId = null) - } - - override fun onDataChannelClose(label: String) { - log.i { "Data channel closed" } - scope.launch { leave() } - } - } - - private fun handleIncomingMessage(data: String, fromGuestId: String?) { - try { - val message = json.decodeFromString(JamMessage.serializer(), data) - _incomingMessages.tryEmit(message) - when (message) { - is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> { - _incomingSuggestions.tryEmit(message) - } - - is JamMessage.Hello -> { - if (_role.value == JamRole.Host && fromGuestId != null) { - handleHello(fromGuestId, message) - } - } - - is JamMessage.Welcome -> { - if (_role.value == JamRole.Guest) { - _localParticipantId.value = message.participantId - _participants.update { current -> - current.map { participant -> - if (participant.isHost) { - participant.copy(displayName = message.hostName.ifBlank { participant.displayName }) - } else { - participant - } - } - } - log.i { "Welcome: joined as ${message.participantId}" } - } - } - - is JamMessage.ParticipantList -> { - if (_role.value == JamRole.Guest) { - _participants.value = message.participants - } - } - - is JamMessage.Kick -> { - if (_role.value == JamRole.Guest) { - log.i { "Kicked by host: ${message.reason}" } - scope.launch { leave() } - } - } - - is JamMessage.Leave -> { - if (_role.value == JamRole.Host && fromGuestId != null) { - scope.launch { removeGuest(fromGuestId) } - } else if (_role.value == JamRole.Guest) { - scope.launch { leave() } - } - } - - else -> Unit - } - } catch (e: Exception) { - log.w(e) { "Failed to parse jam message" } - } - } - - private fun handleHello(guestId: String, hello: JamMessage.Hello) { - val deviceId = hello.deviceId - if (deviceId in bannedDeviceIds) { - log.w { "Rejecting banned device $deviceId" } - scope.launch { - sendMessage( - JamMessage.Kick(guestId, "banned by host"), - guestId = guestId, - ) - removeGuest(guestId) - } - return - } - guestDeviceIds[guestId] = deviceId - _participants.update { current -> - current.map { participant -> - if (participant.id == guestId) { - participant.copy(displayName = hello.displayName.ifBlank { participant.displayName }) - } else { - participant - } - } - } - scope.launch { - sendMessage( - JamMessage.Welcome(hostDisplayName, guestId), - guestId = guestId, - ) - broadcastParticipantList() - // Give the newly joined guest the current queue + playback state. - queueSyncManager.broadcastNow() - } - } - - private suspend fun shutdownAll() { - pendingInvites.values.forEach { runCatching { it.shutdown() } } - connectedGuests.values.forEach { runCatching { it.shutdown() } } - runCatching { hostConnection?.shutdown() } - pendingInvites.clear() - connectedGuests.clear() - hostConnection = null - } -} - -private const val CHANNEL_LABEL = "jam" - -private fun MutableStateFlow.update(transform: (T) -> T) { - value = transform(value) -} - -private fun randomShortId(): String { - val chars = "0123456789abcdef" - return buildString(8) { - repeat(8) { - append(chars[kotlin.random.Random.nextInt(chars.length)]) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt deleted file mode 100644 index 23e9d26e..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt +++ /dev/null @@ -1,316 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.jam - -import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json - -/** - * Keeps playback in sync across a jam session (star topology). - * - * On the **host**: applies incoming playback commands and guest suggestions to the - * host's player, and broadcasts the current queue + playback state to all guests - * (on queue changes and periodically, so play/pause/seek/position propagate). - * - * On the **guest**: mirrors the host's queue into the local player and applies - * playback commands. The guest's queue is read-only — the host has authority. - */ -class QueueSyncManager( - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, - private val jamSession: JamSessionService, -) { - private val log = Logger.withTag("QueueSyncManager") - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _isSyncing = MutableStateFlow(false) - val isSyncing: StateFlow = _isSyncing.asStateFlow() - - private var hostBroadcastJob: Job? = null - private var hostCommandJob: Job? = null - private var guestApplyJob: Job? = null - private var guestCommandJob: Job? = null - - /** Guest side: the last applied queue snapshot, used to detect real queue changes. */ - private var lastAppliedItems: List = emptyList() - - /** Guest side: tracks the player was told to start playing from. */ - private var lastAppliedCurrentIndex = -1 - - fun start() { - if (_isSyncing.value) return - _isSyncing.value = true - - when (jamSession.role.value) { - JamRole.Host -> startHostSync() - JamRole.Guest -> startGuestSync() - null -> { - _isSyncing.value = false - return - } - } - } - - fun stop() { - _isSyncing.value = false - hostBroadcastJob?.cancel() - hostCommandJob?.cancel() - guestApplyJob?.cancel() - guestCommandJob?.cancel() - hostBroadcastJob = null - hostCommandJob = null - guestApplyJob = null - guestCommandJob = null - lastAppliedItems = emptyList() - lastAppliedCurrentIndex = -1 - } - - // ---------- Host side ---------- - - private fun startHostSync() { - // Apply commands/suggestions coming from guests. - hostCommandJob = scope.launch { - jamSession.role.first { it != null } - if (jamSession.role.value != JamRole.Host) return@launch - - jamSession.incomingMessages.collect { message -> - when (message) { - is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command) - is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem)) - is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks) - else -> {} - } - } - } - - // Broadcast state on queue changes and periodically. - hostBroadcastJob = scope.launch { - jamSession.role.first { it != null } - if (jamSession.role.value != JamRole.Host) return@launch - - // Queue changes (separate coroutine — collect() never returns). - launch { - audioPlayerQueue.queueFlow.collect { - broadcastCurrentState() - } - } - - // Periodic tick so play/pause/seek/position propagate to guests. - while (isActive) { - delay(2_000) - broadcastCurrentState() - } - } - } - - /** Immediately pushes the current queue + playback state to all guests. */ - suspend fun broadcastNow() { - if (jamSession.role.value == JamRole.Host) { - broadcastCurrentState() - } - } - - private suspend fun broadcastCurrentState() { - val queue = audioPlayerQueue.getQueue() - val current = audioPlayerQueue.getCurrentQueueEntry() - val currentIndex = if (current != null) { - queue.indexOfFirst { it.matchesEntry(current) } - } else { - -1 - } - jamSession.broadcastQueueState( - items = queue.map(JamMediaItem::fromQueueEntry), - currentIndex = currentIndex.coerceAtLeast(0), - isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, - positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, - ) - } - - private suspend fun acceptSuggestion(items: List) { - if (items.isEmpty()) return - val entries = items.map { it.toQueueEntry() } - log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" } - audioPlayerQueue.addAllToQueue(entries) - } - - // ---------- Guest side ---------- - - private fun startGuestSync() { - guestApplyJob = scope.launch { - jamSession.incomingMessages.collect { message -> - if (message !is JamMessage.QueueState) return@collect - applyQueueState(message) - } - } - - guestCommandJob = scope.launch { - jamSession.incomingMessages.collect { message -> - if (message !is JamMessage.PlaybackCommand) return@collect - applyPlaybackCommand(message.command) - } - } - } - - private suspend fun applyQueueState(state: JamMessage.QueueState) { - log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" } - - // Items that carry neither a track id nor a usable URL can't be played - // on this device — skip them instead of crashing the player. - val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } - - val queueChanged = playableItems != lastAppliedItems - if (queueChanged) { - lastAppliedItems = playableItems - lastAppliedCurrentIndex = state.currentIndex - val entries = playableItems.map { it.toQueueEntry() } - runCatching { - // Load through the queue repository (like the host does) so the - // stream proxy can resolve the tracks — it only knows tracks in - // queueFlow. - audioPlayerQueue.load( - entries = entries, - autoPlay = state.isPlaying, - startPosition = state.currentIndex.coerceIn(0, entries.lastIndex.coerceAtLeast(0)), - ) - }.onFailure { e -> - log.e(e) { "Failed to apply jam queue to local player" } - } - return - } - - // Same queue: just sync playback state. Avoid seeking on every tick unless - // the drift is meaningful. - if (state.currentIndex != lastAppliedCurrentIndex) { - lastAppliedCurrentIndex = state.currentIndex - runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) } - .onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } } - } - val currentState = audioPlayer.playerStateFlow.value - if (state.isPlaying && currentState != PlayerState.PLAYING) { - audioPlayer.play() - } else if (!state.isPlaying && currentState == PlayerState.PLAYING) { - audioPlayer.pause() - } - val driftMs = kotlin.math.abs( - audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs - ) - if (driftMs > POSITION_SYNC_THRESHOLD_MS) { - runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) } - .onFailure { e -> log.w(e) { "Failed to sync position" } } - } - } - - private suspend fun applyPlaybackCommand(command: PlaybackCmd) { - log.d { "Applying playback command: $command" } - runCatching { - when (command) { - PlaybackCmd.Play -> audioPlayer.play() - PlaybackCmd.Pause -> audioPlayer.pause() - PlaybackCmd.Toggle -> { - if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - - is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) - PlaybackCmd.SkipNext -> audioPlayer.skipToNext() - PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() - is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) - is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) - is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) - is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) - } - }.onFailure { e -> - log.w(e) { "Failed to apply playback command: $command" } - } - } - - /** - * Build a queue entry from a jam media item. Streaming tracks carry their id - * so the device's own queue/stream proxy can resolve a playable URL later. - */ - private fun JamMediaItem.toQueueEntry(): QueueEntry = when { - trackId.isNotBlank() -> QueueEntry.StreamingTrack( - track = MetadataTrack( - id = trackId, - title = title, - durationMs = durationMs, - trackNumber = null, - discNumber = null, - artists = listOf( - MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) - ), - album = null, - thumbnails = null, - explicit = null, - popularity = null, - isrcCode = null, - externalUri = null, - ), - url = "", - protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } - .getOrDefault(StreamProtocol.PROGRESSIVE), - ) - - else -> QueueEntry.LocalTrack( - name = title, - artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, - duration = durationMs, - album = album.ifBlank { null }, - coverBytes = null, - url = url, - ) - } - - private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean { - return when { - this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> - this.track.id == other.track.id - - this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> - this.url == other.url && this.name == other.name - - else -> false - } - } - - companion object { - /** Seek the guest only when its position drifts more than this from the host. */ - private const val POSITION_SYNC_THRESHOLD_MS = 3_000L - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 01a77c4b..7857b1f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -21,16 +21,18 @@ import co.touchlab.kermit.Logger import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamMediaItem import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -87,7 +89,7 @@ class RemotePlaybackController( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, - private val jamSession: JamSessionService, + private val jamRoomService: JamRoomService, ) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -95,6 +97,10 @@ class RemotePlaybackController( private val _pendingRequest = MutableStateFlow(null) val pendingRequest: StateFlow = _pendingRequest.asStateFlow() + /** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */ + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } @@ -168,34 +174,41 @@ class RemotePlaybackController( _pendingRequest.value = null scope.launch { try { - when (jamSession.role.value) { + when (jamRoomService.role.value) { JamRole.Host -> executeLocally(request) JamRole.Guest -> suggestToJam(request) - null -> {} + null -> return@launch } + _events.emit(confirmationMessage(request)) } catch (e: Exception) { logger.e(e) { "Failed to send content to jam session" } } } } + private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) { + PlaybackDestinationAction.Play -> "Playing on the jam queue" + PlaybackDestinationAction.AddToQueue -> "Added to the jam queue" + PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue" + } + private suspend fun suggestToJam(request: PlaybackDestinationRequest) { when (request) { is PlaybackDestinationRequest.Collection -> { val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id) if (tracks.isNotEmpty()) { - jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() }) + jamRoomService.suggestPlaylist(tracks) logger.i { "Suggested ${tracks.size} track(s) to the jam session" } } } is PlaybackDestinationRequest.Track -> { - jamSession.suggestTrack(request.track.toJamMediaItem()) + jamRoomService.suggestTrack(request.track) } is PlaybackDestinationRequest.Tracks -> { if (request.tracks.isNotEmpty()) { - jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() }) + jamRoomService.suggestPlaylist(request.tracks) } } } @@ -204,7 +217,9 @@ class RemotePlaybackController( // ---------- Internals ---------- private fun request(request: PlaybackDestinationRequest) { - if (isRemoteConnected()) { + // The picker offers "This Device", a connected remote device, and an + // active jam session — show it whenever more than one destination exists. + if (isRemoteConnected() || jamRoomService.role.value != null) { _pendingRequest.value = request } else { executeLocally(request) @@ -368,6 +383,4 @@ class RemotePlaybackController( album?.id == other.album?.id && artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } } -} - -private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this) \ No newline at end of file +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 6fd70573..906c1492 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient @@ -41,6 +41,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist +import kotlinx.coroutines.flow.map import org.koin.compose.koinInject /** @@ -52,10 +53,11 @@ import org.koin.compose.koinInject fun PlayDestinationPickerHost() { val controller = koinInject() val remoteControlClient = koinInject() - val jamSession = koinInject() + val jamRoomService = koinInject() val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - val jamActive by jamSession.isActive.collectAsStateWithLifecycle() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val pendingRequest = request ?: return diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index adc4d246..b7f76b00 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,25 +17,18 @@ package dev.krtirtho.spotube.modules.jam -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -48,38 +41,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomCode import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import org.koin.compose.viewmodel.koinViewModel +/** + * Group Jam session screen. Playback controls and the queue live in the app's + * regular player / queue sheet (the shared jam queue is the local queue), so + * this screen only covers participation and session management. + */ @Composable fun JamScreen( navigationCommands: NavigationCommands, @@ -110,48 +93,19 @@ fun JamScreen( } when { - !state.isActive && state.incomingOfferSdp != null -> IncomingInviteView( - hostName = state.incomingHostName.orEmpty(), - onJoin = viewModel::joinWithIncomingInvite, - onDismiss = viewModel::dismissIncomingInvite, - ) - !state.isActive -> CreateOrJoinView( + state = state, onCreate = viewModel::createSession, - onJoin = viewModel::joinWithPasted, + onJoin = viewModel::joinWithCode, ) - state.role == JamRole.Host -> HostSessionView( + else -> SessionView( state = state, - playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, - onNewInvite = viewModel::generateNewInvite, - onSubmitAnswer = viewModel::submitAnswerPasted, - onShare = viewModel::share, + onShareCode = viewModel::shareRoomCode, onLeave = viewModel::leave, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onJumpTo = viewModel::jumpTo, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoop = viewModel::cycleLoopMode, onKick = viewModel::kickParticipant, onBan = viewModel::banParticipant, ) - - else -> GuestSessionView( - state = state, - playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, - onShare = viewModel::share, - onLeave = viewModel::leave, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onJumpTo = viewModel::jumpTo, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoop = viewModel::cycleLoopMode, - ) } } } @@ -173,18 +127,33 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) { @Composable private fun CreateOrJoinView( + state: JamUiState, onCreate: () -> Unit, onJoin: (String) -> Unit, ) { - var tab by remember { mutableIntStateOf(0) } + var tab by rememberSaveable { mutableIntStateOf(0) } var pasted by rememberSaveable { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = "Listen together with friends over a peer-to-peer connection.", + text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", style = MaterialTheme.typography.titleMedium, ) + if (!state.brokerConfigured) { + Text( + text = "No jam broker configured — set one up in Settings to host or join a session.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + text = "Broker: ${state.brokerHost}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( selected = tab == 0, @@ -201,34 +170,36 @@ private fun CreateOrJoinView( if (tab == 0) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Start a session as the host. You'll get a shareable invite link " + - "to send to friends; when they accept, they appear here.", + text = "Start a session as the host. You'll get a 6-character room code to " + + "share with friends; you control the queue.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button(onClick = onCreate) { + Button( + onClick = onCreate, + enabled = state.brokerConfigured, + ) { Text("Create Session") } } } else { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Paste the invite link the host shared with you.", + text = "Enter the 6-character room code the host shared with you.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( value = pasted, - onValueChange = { pasted = it }, + onValueChange = { pasted = JamRoomCode.normalize(it) }, modifier = Modifier.fillMaxWidth(), - label = { Text("Invite link") }, - placeholder = { Text("spotube://jam/invite?...") }, - minLines = 2, - maxLines = 6, + label = { Text("Room code") }, + placeholder = { Text("ABC123") }, + singleLine = true, ) Button( onClick = { onJoin(pasted) }, - enabled = pasted.isNotBlank(), + enabled = state.brokerConfigured && JamRoomCode.isValid(pasted), ) { Text("Join Session") } @@ -238,401 +209,54 @@ private fun CreateOrJoinView( } @Composable -private fun IncomingInviteView( - hostName: String, - onJoin: () -> Unit, - onDismiss: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "$hostName invited you to a jam session", - style = MaterialTheme.typography.titleMedium, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onJoin) { - Text("Join") - } - OutlinedButton(onClick = onDismiss) { - Text("Ignore") - } - } - } -} - -@Composable -private fun HostSessionView( +private fun SessionView( state: JamUiState, - playerState: JamPlayerUiState, - onNewInvite: () -> Unit, - onSubmitAnswer: (String) -> Unit, - onShare: (String) -> Unit, + onShareCode: () -> Unit, onLeave: () -> Unit, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onJumpTo: (Int) -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, onKick: (String) -> Unit, onBan: (String) -> Unit, ) { - val clipboard = LocalClipboardManager.current - var pastedAnswer by rememberSaveable { mutableStateOf("") } + val isHost = state.role == JamRole.Host Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan) - - JamNowPlayingView( - playerState = playerState, - onTogglePlayPause = onTogglePlayPause, - onSkipNext = onSkipNext, - onSkipPrevious = onSkipPrevious, - onToggleShuffle = onToggleShuffle, - onCycleLoop = onCycleLoop, - ) - - HorizontalDivider() - - Text( - text = "Invite someone", - style = MaterialTheme.typography.titleSmall, - ) - val inviteLink = state.inviteLink - if (inviteLink != null) { - ShareableLinkBox( - label = "Invite link", - link = inviteLink, - onCopy = { clipboard.setText(AnnotatedString(inviteLink)) }, - onShare = { onShare(inviteLink) }, + if (!state.isConnected) { + Text( + text = "Connecting to the session…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - OutlinedButton(onClick = onNewInvite) { - Text("Generate new invite") + + ParticipantsSection( + participants = state.participants, + isHost = isHost, + onKick = onKick, + onBan = onBan, + ) + + if (isHost) { + HorizontalDivider() + + Text( + text = "Invite someone", + style = MaterialTheme.typography.titleSmall, + ) + RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) } HorizontalDivider() Text( - text = "Accept a guest's answer", - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = "When your guest sends back their answer link, paste it below.", + text = "The queue and playback controls are in the player at the bottom of the app — " + + "the jam queue is shared with every participant.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( - value = pastedAnswer, - onValueChange = { pastedAnswer = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Answer link or SDP") }, - minLines = 2, - maxLines = 6, - ) - Button( - onClick = { - onSubmitAnswer(pastedAnswer) - pastedAnswer = "" - }, - enabled = pastedAnswer.isNotBlank(), - ) { - Text("Accept Answer") - } - - HorizontalDivider() - - JamQueueView( - queue = playerState.queue, - onJumpTo = onJumpTo, - ) LeaveButton(onLeave) } } -@Composable -private fun GuestSessionView( - state: JamUiState, - playerState: JamPlayerUiState, - onShare: (String) -> Unit, - onLeave: () -> Unit, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onJumpTo: (Int) -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, -) { - val clipboard = LocalClipboardManager.current - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {}) - - val answerLink = state.answerLink - when { - state.isConnected -> { - JamNowPlayingView( - playerState = playerState, - onTogglePlayPause = onTogglePlayPause, - onSkipNext = onSkipNext, - onSkipPrevious = onSkipPrevious, - onToggleShuffle = onToggleShuffle, - onCycleLoop = onCycleLoop, - ) - - JamQueueView( - queue = playerState.queue, - onJumpTo = onJumpTo, - ) - } - - answerLink == null -> { - Text( - text = "Connecting to the session...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - else -> { - Text( - text = "Almost there! Send your answer back to the host:", - style = MaterialTheme.typography.titleSmall, - ) - ShareableLinkBox( - label = "Answer link", - link = answerLink, - onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, - onShare = { onShare(answerLink) }, - ) - } - } - - LeaveButton(onLeave) - } -} - -@Composable -private fun JamNowPlayingView( - playerState: JamPlayerUiState, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - AsyncImage( - model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() }, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(64.dp) - .clip(MaterialTheme.shapes.medium), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = playerState.currentTitle ?: "Nothing playing", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = playerState.currentArtist ?: "—", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatJamDuration(playerState.positionMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = formatJamDuration(playerState.durationMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = onToggleShuffle, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = "Shuffle", - tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - IconButton( - onClick = onSkipPrevious, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = onTogglePlayPause, - theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape), - modifier = Modifier.size(64.dp), - ) { - Icon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(32.dp), - ) - } - IconButton( - onClick = onSkipNext, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") - } - IconButton( - onClick = onCycleLoop, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = "Loop mode", - tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } -} - -@Composable -private fun JamQueueView( - queue: List, - onJumpTo: (Int) -> Unit, -) { - var expanded by rememberSaveable { mutableStateOf(false) } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Queue (${queue.size})", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = if (expanded) "Collapse queue" else "Expand queue", - modifier = Modifier - .size(20.dp) - .graphicsLayer { rotationZ = if (expanded) 180f else 0f }, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (queue.isEmpty()) { - Text( - text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else if (expanded) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 280.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - itemsIndexed(queue) { index, item -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onJumpTo(index) } - .padding(vertical = 6.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - AsyncImage( - model = item.coverUrl.takeIf { it.isNotBlank() }, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(40.dp) - .clip(MaterialTheme.shapes.small), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.title, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (item.isCurrent) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - Text( - text = item.artist, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Text( - text = formatJamDuration(item.durationMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } else { - Text( - text = "Tap to view the shared queue.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - @Composable private fun ParticipantsSection( participants: List, @@ -684,35 +308,26 @@ private fun ParticipantsSection( } } -private fun formatJamDuration(ms: Long): String { - val totalSeconds = (ms / 1000).coerceAtLeast(0) - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} - @Composable -private fun ShareableLinkBox( - label: String, - link: String, - onCopy: () -> Unit, +private fun RoomCodeBox( + code: String, onShare: () -> Unit, ) { + val clipboard = LocalClipboardManager.current val viewModel: JamViewModel = koinViewModel() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SelectionContainer { - OutlinedTextField( - value = link, - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - label = { Text(label) }, - minLines = 2, - maxLines = 6, + Text( + text = code, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp), ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onCopy) { + Button(onClick = { clipboard.setText(AnnotatedString(code)) }) { Text("Copy") } if (viewModel.supportsNativeShare) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt index b83e55fa..92dcc936 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -20,22 +20,10 @@ package dev.krtirtho.spotube.modules.jam import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.MediaItem -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService -import dev.krtirtho.spotube.core.jam.JamInviteCodec -import dev.krtirtho.spotube.core.jam.JamInviteLink -import dev.krtirtho.spotube.core.jam.JamLoopMapping -import dev.krtirtho.spotube.core.jam.JamMediaItem -import dev.krtirtho.spotube.core.jam.JamMessage import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamSessionService -import dev.krtirtho.spotube.core.jam.PlaybackCmd +import dev.krtirtho.spotube.core.jam.JamRoomCode +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.settings.SettingsProvider @@ -45,7 +33,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class JamUiState( @@ -53,377 +40,104 @@ data class JamUiState( val isConnected: Boolean = false, val role: JamRole? = null, val participants: List = emptyList(), - /** Host: deep link containing this session's SDP offer, ready to share. */ - val inviteLink: String? = null, - /** Guest: deep link containing our SDP answer, to send back to the host. */ - val answerLink: String? = null, - /** Guest: offer received via deep link (or paste), waiting for confirmation. */ - val incomingHostName: String? = null, - val incomingOfferSdp: String? = null, + val roomCode: String? = null, + val brokerHost: String = "", + val brokerConfigured: Boolean = false, val error: String? = null, ) -data class JamQueueUiItem( - val id: String, - val title: String, - val artist: String, - val album: String, - val durationMs: Long, - val coverUrl: String, - val isCurrent: Boolean, -) - -data class JamPlayerUiState( - val queue: List = emptyList(), - val currentIndex: Int = -1, - val currentTitle: String? = null, - val currentArtist: String? = null, - val currentCoverUrl: String? = null, - val isPlaying: Boolean = false, - val positionMs: Long = 0, - val durationMs: Long = 0, - val shuffleEnabled: Boolean = false, - val loopMode: String = "none", -) - class JamViewModel( - private val jamSession: JamSessionService, - private val deepLinks: JamDeepLinkService, + private val jamRoomService: JamRoomService, private val shareService: ShareService, private val settingsProvider: SettingsProvider, - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, ) : ViewModel() { - private val _uiState = MutableStateFlow(JamUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _localError = MutableStateFlow(null) val supportsNativeShare: Boolean = getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS - init { - viewModelScope.launch { - // Mirror live session state into the UI state. - jamSession.isActive.collect { active -> - _uiState.update { - it.copy( - isActive = active, - isConnected = jamSession.isConnected.value, - role = jamSession.role.value, - participants = jamSession.participants.value, - inviteLink = if (!active) null else it.inviteLink, - answerLink = if (!active) null else it.answerLink, - incomingOfferSdp = if (!active) it.incomingOfferSdp else null, - incomingHostName = if (!active) it.incomingHostName else null, - ) - } - } - } - viewModelScope.launch { - jamSession.participants.collect { participants -> - _uiState.update { it.copy(participants = participants) } - } - } - viewModelScope.launch { - jamSession.isConnected.collect { connected -> - _uiState.update { it.copy(isConnected = connected) } - } - } - viewModelScope.launch { - deepLinks.pendingLink.collect { link -> - handleDeepLink(link) - } - } - } - - /** - * The jam player state: the shared queue + current playback, built from the - * local player (the host's queue IS the jam queue; on guests the synced - * mirror lives in the local player). - */ - val jamPlayerState: StateFlow = combine( - audioPlayerQueue.queueFlow, - audioPlayerQueue.currentQueueEntryFlow, - audioPlayer.playlistFlow, - audioPlayer.currentMediaItemFlow, - audioPlayer.playerStateFlow, - audioPlayer.positionFlow, - audioPlayer.durationFlow, - audioPlayer.loopStateFlow, - audioPlayer.shuffleModeFlow, + val uiState: StateFlow = combine( + jamRoomService.role, + jamRoomService.participants, + jamRoomService.isConnected, + jamRoomService.roomCode, + jamRoomService.connectionError, + settingsProvider.settingsState, + _localError, ) { values -> - val queue: List = values[0] as List - val currentEntry: QueueEntry? = values[1] as QueueEntry? - val playlist: List = values[2] as List - val currentItem: MediaItem? = values[3] as MediaItem? - val playerState: PlayerState = values[4] as PlayerState - val position: kotlin.time.Duration = values[5] as kotlin.time.Duration - val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration - val loop: LoopState = values[7] as LoopState - val shuffle: Boolean = values[8] as Boolean + @Suppress("UNCHECKED_CAST") + val role = values[0] as JamRole? + @Suppress("UNCHECKED_CAST") + val participants = values[1] as List + val isConnected = values[2] as Boolean + val roomCode = values[3] as String? + val connectionError = values[4] as String? + val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings + val localError = values[6] as String? - val isHost = jamSession.role.value == JamRole.Host - - val items: List - val currentIndex: Int - val currentTitle: String? - val currentArtist: String? - val currentCoverUrl: String? - - if (isHost) { - val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) } - val index = if (currentEntry != null) { - queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) } - } else { - -1 - } - items = queueItems.mapIndexed { i, item -> - item.toUiItem(i == index) - } - currentIndex = index - currentTitle = queueItems.getOrNull(index)?.title - currentArtist = queueItems.getOrNull(index)?.artist - currentCoverUrl = queueItems.getOrNull(index)?.coverUrl - } else { - val index = playlist.indexOf(currentItem) - items = playlist.mapIndexed { i, item -> - JamMediaItem.fromMediaItem(item).toUiItem(i == index) - } - currentIndex = index - currentTitle = currentItem?.title - currentArtist = currentItem?.artist - currentCoverUrl = currentItem?.coverURL - } - - JamPlayerUiState( - queue = items, - currentIndex = currentIndex, - currentTitle = currentTitle, - currentArtist = currentArtist, - currentCoverUrl = currentCoverUrl, - isPlaying = playerState == PlayerState.PLAYING, - positionMs = position.inWholeMilliseconds, - durationMs = duration.inWholeMilliseconds, - shuffleEnabled = shuffle, - loopMode = loop.name.lowercase(), + JamUiState( + isActive = role != null, + isConnected = isConnected, + role = role, + participants = participants, + roomCode = roomCode, + brokerHost = settings?.jamBroker?.host.orEmpty(), + brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), + error = localError ?: connectionError, ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) - // ---------- Playback controls ---------- + // ---------- Session lifecycle ---------- - fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle) - - fun skipNext() = sendOrApply(PlaybackCmd.SkipNext) - - fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious) - - fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs)) - - fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index)) - - fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled)) - - fun cycleLoopMode() { - val next = when (jamPlayerState.value.loopMode) { - "none" -> "one" - "one" -> "all" - else -> "none" - } - sendOrApply(PlaybackCmd.SetLoop(next)) - } - - private fun sendOrApply(command: PlaybackCmd) { + fun createSession() { viewModelScope.launch { - if (jamSession.role.value == JamRole.Host) { - applyCommandLocally(command) - } else { - jamSession.sendMessage(JamMessage.PlaybackCommand(command)) - } + jamRoomService.createRoom() + .onFailure { e -> + _localError.value = e.message ?: "Failed to create jam session" + } + .onSuccess { _localError.value = null } } } - private suspend fun applyCommandLocally(command: PlaybackCmd) { - when (command) { - PlaybackCmd.Play -> audioPlayer.play() - PlaybackCmd.Pause -> audioPlayer.pause() - PlaybackCmd.Toggle -> { - if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - - is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) - PlaybackCmd.SkipNext -> audioPlayer.skipToNext() - PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() - is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) - is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) - is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) - is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) + fun joinWithCode(input: String) { + val code = JamRoomCode.normalize(input) + if (!JamRoomCode.isValid(code)) { + _localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)" + return } + viewModelScope.launch { + jamRoomService.joinRoom(code) + .onFailure { e -> + _localError.value = e.message ?: "Failed to join jam session" + } + .onSuccess { _localError.value = null } + } + } + + fun shareRoomCode() { + val code = uiState.value.roomCode ?: return + shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam") + } + + fun leave() { + viewModelScope.launch { + jamRoomService.leaveRoom() + _localError.value = null + } + } + + fun clearError() { + _localError.value = null } // ---------- Host moderation ---------- fun kickParticipant(participantId: String) { - viewModelScope.launch { jamSession.kickParticipant(participantId) } + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } } fun banParticipant(participantId: String) { - viewModelScope.launch { jamSession.banParticipant(participantId) } - } - - fun createSession() { - viewModelScope.launch { - runCatching { - val offer = jamSession.createSession() - JamInviteCodec.buildHostInvite(localName(), offer) - }.onSuccess { link -> - _uiState.update { it.copy(inviteLink = link, error = null) } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to create session: ${e.message}") } - } - } - } - - fun generateNewInvite() { - viewModelScope.launch { - runCatching { - val invite = jamSession.generateInvite() - JamInviteCodec.buildHostInvite(localName(), invite.sdp) - }.onSuccess { link -> - _uiState.update { it.copy(inviteLink = link, error = null) } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") } - } - } - } - - fun joinWithIncomingInvite() { - val sdp = _uiState.value.incomingOfferSdp ?: return - join(sdp, _uiState.value.incomingHostName) - } - - fun joinWithPasted(input: String) { - val parsed = JamInviteCodec.parse(input) - val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input) - if (sdp == null) { - _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") } - return - } - join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName) - } - - /** - * Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link. - */ - fun submitAnswerPasted(input: String) { - when (val parsed = JamInviteCodec.parse(input.trim())) { - is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName) - else -> { - val sdp = JamInviteCodec.extractSdp(input) - if (sdp == null) { - _uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") } - } else { - acceptAnswerInternal(sdp, "") - } - } - } - } - - fun share(text: String) { - shareService.share(text, "Spotube Group Jam") - } - - fun leave() { - viewModelScope.launch { - jamSession.leave() - deepLinks.clear() - _uiState.update { - JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName) - } - } - } - - fun clearError() { - _uiState.update { it.copy(error = null) } - } - - fun dismissIncomingInvite() { - deepLinks.clear() - _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) } - } - - private fun join(offerSdp: String, hostName: String? = null) { - viewModelScope.launch { - runCatching { - val answer = jamSession.joinSession(offerSdp, hostName) - JamInviteCodec.buildGuestAnswer(localName(), answer) - }.onSuccess { link -> - _uiState.update { - it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null) - } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to join session: ${e.message}") } - } - } - } - - private fun acceptAnswerInternal(answerSdp: String, peerName: String) { - viewModelScope.launch { - val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) } - .getOrDefault(false) - if (!accepted) { - _uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") } - } else { - _uiState.update { it.copy(error = null) } - } - } - } - - private suspend fun handleDeepLink(link: JamInviteLink?) { - when (link) { - is JamInviteLink.HostInvite -> { - if (!jamSession.isActive.value) { - _uiState.update { - it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp) - } - } - } - - is JamInviteLink.GuestAnswer -> { - if (jamSession.role.value == JamRole.Host) { - acceptAnswerInternal(link.sdp, link.peerName) - } - } - - null -> Unit - } - } - - private fun localName(): String = - settingsProvider.settingsState.value?.jamParticipantName.orEmpty() -} - -private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem( - id = if (trackId.isNotBlank()) trackId else url, - title = title, - artist = artist, - album = album, - durationMs = durationMs, - coverUrl = coverUrl, - isCurrent = isCurrent, -) - -private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean { - return when { - this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> - this.track.id == other.track.id - - this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> - this.url == other.url && this.name == other.name - - else -> false + viewModelScope.launch { jamRoomService.banParticipant(participantId) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index 1878ae96..3a6792ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -61,8 +61,10 @@ data class UserSettings( val remoteControlDeviceName: String = "", val remoteControlDeviceId: String = "", - // Group Jam (P2P) + // Group Jam (MQTT) val jamParticipantName: String = "", + val jamBroker: JamBroker = JamBroker(), + val lastJamCode: String = "", // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder @@ -86,3 +88,20 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, ) + +/** + * Configuration for the MQTT broker used by Group Jam. The host is a placeholder + * until a real broker is configured; users can self-host and point the app at it. + */ +@Serializable +data class JamBroker( + val name: String = "", + val host: String = "test.mosquitto.org", + val port: Int = 1883, + val useTls: Boolean = false, + val username: String? = null, + val password: String? = null, + val clientIdPrefix: String = "spotube", + val keepAliveSeconds: Int = 30, + val connectionTimeoutSeconds: Int = 10, +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 7601f014..2321ecef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -43,6 +43,7 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection import dev.krtirtho.spotube.modules.settings.sections.cacheSection import dev.krtirtho.spotube.modules.settings.sections.desktopSection import dev.krtirtho.spotube.modules.settings.sections.downloadsSection +import dev.krtirtho.spotube.modules.settings.sections.jamSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.pluginsSection @@ -110,6 +111,11 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, ) + if (settingsState != null) + jamSection( + settings = settingsState!!, + settingsViewModel = settingsViewModel, + ) if (settingsState != null) cacheSection( settings = settingsState!!, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt new file mode 100644 index 00000000..a15326da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -0,0 +1,208 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.settings.sections + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.jam.JamRoomClient +import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard +import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard +import dev.krtirtho.spotube.resources.iconsax.CustomServer +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_jam_broker_client_id +import spotube.composeapp.generated.resources.settings_jam_broker_host +import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_password +import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note +import spotube.composeapp.generated.resources.settings_jam_broker_port +import spotube.composeapp.generated.resources.settings_jam_broker_test +import spotube.composeapp.generated.resources.settings_jam_broker_test_fail +import spotube.composeapp.generated.resources.settings_jam_broker_test_ok +import spotube.composeapp.generated.resources.settings_jam_broker_testing +import spotube.composeapp.generated.resources.settings_jam_broker_tls +import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_section_jam + +internal fun LazyListScope.jamSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, +) { + val broker = settings.jamBroker + + settingsSectionHeader(Res.string.settings_section_jam) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_host), + subtitle = stringResource( + Res.string.settings_jam_broker_host_subtitle, + broker.host.ifBlank { "—" }, + broker.port, + ), + value = broker.host, + onValueSaved = { host -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(host = host)) + } + }, + placeholder = "broker.example.com", + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_host), + ) + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_port), + value = broker.port.toString(), + onValueSaved = { port -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883)) + } + }, + placeholder = "1883", + normalize = { it.filter { c -> c.isDigit() }.take(5) }, + validate = { input -> + val port = input.toIntOrNull() + if (port == null || port !in 1..65535) "Invalid port" else null + }, + ) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_jam_broker_tls), + checked = broker.useTls, + onCheckedChange = { tls -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(useTls = tls)) + } + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_username), + value = broker.username.orEmpty(), + onValueSaved = { username -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) + } + }, + placeholder = "anonymous", + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_password), + value = broker.password.orEmpty(), + onValueSaved = { password -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) + } + }, + placeholder = "••••••••", + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_client_id), + value = broker.clientIdPrefix, + onValueSaved = { prefix -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) + } + }, + placeholder = "spotube", + ) + }, + { + val jamClient = koinInject() + val scope = rememberCoroutineScope() + var testing by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) { + Button( + onClick = { + testing = true + result = null + scope.launch { + val outcome = jamClient.testConnection(broker) + result = outcome.fold( + onSuccess = { ok -> "OK: $ok" }, + onFailure = { e -> "ERR: ${e.message ?: "unknown"}" }, + ) + testing = false + } + }, + enabled = broker.host.isNotBlank() && !testing, + ) { + Text( + text = if (testing) { + stringResource(Res.string.settings_jam_broker_testing) + } else { + stringResource(Res.string.settings_jam_broker_test) + }, + ) + } + result?.let { message -> + val ok = message.startsWith("OK:") + Text( + text = message.removePrefix("OK:").removePrefix("ERR:"), + style = MaterialTheme.typography.bodySmall, + color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + }, + { + Text( + text = stringResource(Res.string.settings_jam_broker_placeholder_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + }, + ) + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index 14fe94f9..b81d2686 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberBottomSheetScaffoldState @@ -70,6 +73,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent @@ -95,6 +99,13 @@ fun AppShell( content: @Composable () -> Unit, ) { val navigatorCommands: NavigationCommands = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(remotePlaybackController) { + remotePlaybackController.events.collect { message -> + snackbarHostState.showSnackbar(message) + } + } val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() @@ -122,6 +133,13 @@ fun AppShell( PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 96.dp), + ) + val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 71faa53b..c9d1a870 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -68,7 +68,7 @@ class PlayerQueueContentViewModel( } queue.mapIndexed { index, entry -> val title: String - val subtitle: String + var subtitle: String val durationMs: Long val imageUrl: String? @@ -89,6 +89,11 @@ class PlayerQueueContentViewModel( } } + val addedBy = entry.addedBy + if (addedBy.isNotBlank()) { + subtitle = "$subtitle • Added by $addedBy" + } + QueueItemUi( id = "${entry.url}@$index", title = title, diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index dff859ba..f8e0e84e 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -1,9 +1,7 @@ mod metadata; mod discord_rpc; -mod webrtc_p2p; pub use metadata::*; pub use discord_rpc::*; -pub use webrtc_p2p::*; uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs deleted file mode 100644 index c40e9a69..00000000 --- a/composeApp/src/commonMain/rust/webrtc_p2p.rs +++ /dev/null @@ -1,340 +0,0 @@ -/* - * 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 . - */ - -use std::sync::Arc; -use std::time::Duration; - -use parking_lot::Mutex; -use rtc::ice::mdns::MulticastDnsMode; -use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors; -use rtc::peer_connection::configuration::setting_engine::SettingEngine; -use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit}; -use webrtc::peer_connection::{ - MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, - RTCConfigurationBuilder, RTCIceGatheringState, RTCIceServer, RTCPeerConnectionIceEvent, - RTCPeerConnectionState, RTCSessionDescription, Registry, -}; -use webrtc::runtime::channel; - -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum WebrtcError { - #[error("SDP error: {reason}")] - SdpError { reason: String }, - #[error("Connection error: {reason}")] - ConnectionError { reason: String }, - #[error("Data channel error: {reason}")] - DataChannelError { reason: String }, - #[error("Invalid state: {reason}")] - InvalidState { reason: String }, - #[error("Internal error: {reason}")] - Internal { reason: String }, -} - -impl From for WebrtcError { - fn from(e: webrtc::error::Error) -> Self { - WebrtcError::Internal { - reason: format!("{e:?}"), - } - } -} - -#[derive(uniffi::Record)] -pub struct IceServerConfig { - pub urls: Vec, - pub username: String, - pub credential: String, -} - -#[uniffi::export(callback_interface)] -pub trait WebrtcEventHandler: Send + Sync + 'static { - fn on_ice_candidate(&self, candidate: String); - fn on_ice_gathering_state_change(&self, state: String); - fn on_connection_state_change(&self, state: String); - fn on_data_channel_open(&self, label: String); - fn on_data_channel_message(&self, label: String, data: String); - fn on_data_channel_close(&self, label: String); -} - -struct DataChannelEntry { - dc: Arc, - label: String, -} - -#[derive(uniffi::Object)] -pub struct WebrtcPeerConnection { - pc: Arc, - handler: Arc, - channels: Arc>>, - gather_rx: Mutex>, -} - -#[uniffi::export(async_runtime = "tokio")] -pub async fn create_webrtc_peer_connection( - ice_servers: Vec, - handler: Box, -) -> Result, WebrtcError> { - let handler: Arc = Arc::from(handler); - - let mut media_engine = MediaEngine::default(); - media_engine - .register_default_codecs() - .map_err(|e| WebrtcError::Internal { - reason: format!("media_engine: {e:?}"), - })?; - - let registry = register_default_interceptors(Registry::new(), &mut media_engine) - .map_err(|e| WebrtcError::Internal { - reason: format!("interceptor_registry: {e:?}"), - })?; - - let config = RTCConfigurationBuilder::new() - .with_ice_servers( - ice_servers - .into_iter() - .map(|s| RTCIceServer { - urls: s.urls, - username: s.username, - credential: s.credential, - }) - .collect(), - ) - .build(); - - // mDNS adds a multicast UDP socket per peer connection. On some platforms - // (notably Android) that socket can stall and ICE gathering then never - // completes. Real-IP host candidates (no mDNS) work fine alongside STUN/TURN, - // so mDNS is disabled. - let mut setting_engine = SettingEngine::default(); - setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled); - - let (gather_tx, gather_rx) = channel::<()>(1); - let channels = Arc::new(Mutex::new(Vec::new())); - let pc_handler = Arc::new(PeerHandlerBridge { - handler: Arc::clone(&handler), - gather_tx, - channels: Arc::clone(&channels), - }); - - let pc = PeerConnectionBuilder::new() - .with_configuration(config) - .with_setting_engine(setting_engine) - .with_media_engine(media_engine) - .with_interceptor_registry(registry) - .with_handler(pc_handler) - .with_udp_addrs(vec!["0.0.0.0:0"]) - .build() - .await?; - - Ok(Arc::new(WebrtcPeerConnection { - pc: Arc::new(pc) as Arc, - handler, - channels, - gather_rx: Mutex::new(gather_rx), - })) -} - -impl WebrtcPeerConnection { - /// Waits for ICE gathering to reach `Complete` so the local SDP includes all - /// candidates (non-trickle exchange). Must be called after `set_local_description`, - /// which is what starts gathering. - /// - /// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at - /// least one candidate has landed in the local description, a short grace period - /// is enough — the SDP must never leave candidate-less. Hard cap at 5s. - async fn wait_for_ice_gathering(&self) { - let mut gather_rx = self.gather_rx.lock().clone(); - let started = std::time::Instant::now(); - - loop { - let elapsed = started.elapsed(); - if elapsed >= Duration::from_secs(5) { - log::warn!( - "ICE gathering did not complete within 5s; using the candidates gathered so far" - ); - return; - } - - match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await { - Ok(Some(())) => return, // gathering complete - Ok(None) => return, // handler dropped - Err(_) => {} // timed out, keep waiting - } - - // Grace period once candidates are present, so the SDP always carries them. - if elapsed >= Duration::from_secs(1) { - let sdp = self.pc.local_description().await.map(|d| d.sdp); - if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) { - return; - } - } - } - } -} - -#[uniffi::export] -impl WebrtcPeerConnection { - #[uniffi::method(async_runtime = "tokio")] - pub async fn create_offer(&self) -> Result { - let offer = self.pc.create_offer(None).await?; - self.pc.set_local_description(offer.clone()).await?; - self.wait_for_ice_gathering().await; - Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(offer.sdp)) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn create_answer(&self) -> Result { - let answer = self.pc.create_answer(None).await?; - self.pc.set_local_description(answer.clone()).await?; - self.wait_for_ice_gathering().await; - Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(answer.sdp)) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn set_remote_offer(&self, sdp: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::offer(sdp) - .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn set_remote_answer(&self, sdp: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::answer(sdp) - .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn local_description(&self) -> Option { - self.pc.local_description().await.map(|d| d.sdp) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn create_data_channel(&self, label: String) -> Result<(), WebrtcError> { - let dc = self - .pc - .create_data_channel(&label, None::) - .await?; - - spawn_data_channel_poll_loop(Arc::clone(&dc), Arc::clone(&self.handler)); - - self.channels.lock().push(DataChannelEntry { dc, label }); - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn send_data(&self, label: String, data: String) -> Result<(), WebrtcError> { - let dc = { - let channels = self.channels.lock(); - channels - .iter() - .find(|c| c.label == label) - .map(|c| Arc::clone(&c.dc)) - }; - let dc = dc.ok_or_else(|| WebrtcError::InvalidState { - reason: format!("No data channel with label '{label}'"), - })?; - dc.send_text(&data).await?; - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn shutdown(&self) -> Result<(), WebrtcError> { - let channels: Vec> = { - let channels = self.channels.lock(); - channels.iter().map(|c| Arc::clone(&c.dc)).collect() - }; - for dc in channels.iter() { - let _ = dc.close().await; - } - self.pc.close().await?; - Ok(()) - } -} - -struct PeerHandlerBridge { - handler: Arc, - gather_tx: webrtc::runtime::Sender<()>, - channels: Arc>>, -} - -#[async_trait::async_trait] -impl PeerConnectionEventHandler for PeerHandlerBridge { - async fn on_ice_candidate(&self, event: RTCPeerConnectionIceEvent) { - self.handler.on_ice_candidate(event.candidate.to_string()); - } - - async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) { - let s = state.to_string(); - if matches!(state, RTCIceGatheringState::Complete) { - let _ = self.gather_tx.try_send(()); - } - self.handler.on_ice_gathering_state_change(s); - } - - async fn on_connection_state_change(&self, state: RTCPeerConnectionState) { - self.handler.on_connection_state_change(state.to_string()); - } - - async fn on_data_channel(&self, dc: Arc) { - // Register in-band (remote-initiated) channels so send_data() can find - // them — without this, the answering peer can never send anything. - let label = match dc.label().await { - Ok(l) => l, - Err(_) => return, - }; - self.channels - .lock() - .push(DataChannelEntry { dc: Arc::clone(&dc), label }); - spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler)); - } -} - -fn spawn_data_channel_poll_loop( - dc: Arc, - handler: Arc, -) { - ::tokio::spawn(async move { - let label = match dc.label().await { - Ok(l) => l, - Err(_) => return, - }; - while let Some(event) = dc.poll().await { - match event { - DataChannelEvent::OnOpen => { - handler.on_data_channel_open(label.clone()); - } - DataChannelEvent::OnMessage(msg) => { - let text = if msg.is_string { - String::from_utf8_lossy(&msg.data).into_owned() - } else { - format!("[binary:{}bytes]", msg.data.len()) - }; - handler.on_data_channel_message(label.clone(), text); - } - DataChannelEvent::OnClose | DataChannelEvent::OnClosing => { - handler.on_data_channel_close(label.clone()); - if matches!(event, DataChannelEvent::OnClose) { - break; - } - } - _ => {} - } - } - }); -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b515031d..d0951e49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,8 @@ kotlinx-io = "0.9.1" material3 = "1.10.0-alpha05" kotlinx-serialization-json = "1.11.0" materialKolor = "4.1.1" +mqttClient = "2.1.1" +mqttBuffer = "6.30.8" murmurhash = "0.4.2" newpipeextractor = "v0.26.2" newpipeExtractorKmp = "1.3.0" @@ -122,6 +124,10 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } +mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" } +mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } +mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } +mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } From ad994f3ca3024e5603d88a853ff94c70cadb406d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 11 Sep 2026 22:04:59 +0600 Subject: [PATCH 014/132] feat(jam-session): implement track addition to jam session and enhance UI feedback for jam state --- .../krtirtho/spotube/core/jam/JamProtocol.kt | 8 ++ .../spotube/core/jam/JamRoomService.kt | 112 ++++++++++++++++-- .../core/remote/RemotePlaybackController.kt | 72 +++++------ .../core/ui/component/CollectionView.kt | 4 + .../spotube/core/ui/component/TrackList.kt | 19 ++- .../spotube/core/ui/component/TrackOptions.kt | 16 +++ .../spotube/modules/album/AlbumScreen.kt | 8 ++ .../spotube/modules/album/AlbumViewModel.kt | 8 ++ .../spotube/modules/artist/ArtistScreen.kt | 7 ++ .../spotube/modules/artist/ArtistViewModel.kt | 8 ++ .../modules/devices/PlayDestinationPicker.kt | 33 ------ .../modules/playlist/PlaylistScreen.kt | 8 ++ .../modules/playlist/PlaylistViewModel.kt | 8 ++ .../modules/saved_tracks/SavedTracksScreen.kt | 8 ++ .../saved_tracks/SavedTracksViewModel.kt | 8 ++ .../spotube/modules/search/SearchScreen.kt | 24 ++++ .../modules/shell/AppExpandedPlayer.kt | 19 ++- .../spotube/modules/shell/AppLargePlayer.kt | 17 ++- .../shell/player_queue/PlayerQueueContent.kt | 71 ++++++----- .../PlayerQueueContentViewModel.kt | 15 ++- 20 files changed, 353 insertions(+), 120 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt index ac9e35d5..a51b8cb5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -39,6 +39,14 @@ sealed class JamMessage { val items: List, val currentIndex: Int, val shuffleEnabled: Boolean = false, + /** + * Whether guests should follow the host's current index. True when the + * host manually skipped/jumped or loaded a queue; false when the host + * merely auto-advanced because its song ended (guests stay put). + */ + val follow: Boolean = false, + /** Host's live play state — late-joining guests start with it. */ + val isPlaying: Boolean = false, ) : JamMessage() @Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index 8107e1cd..d9618e3c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.random.Random +import kotlin.time.Clock /** * A jam session over MQTT (star topology, host-authoritative queue). @@ -54,6 +55,13 @@ import kotlin.random.Random * - Guests can only add to the queue (suggest); the host applies suggestions. * - If the host leaves, the participant with the lowest client id takes over. */ +private data class PlaybackBroadcast( + val queue: List, + val current: QueueEntry?, + val shuffle: Boolean, + val playerState: PlayerState, +) + class JamRoomService( private val jamClient: JamRoomClient, private val audioPlayer: AudioPlayerInterface, @@ -83,12 +91,28 @@ class JamRoomService( private var localClientId: String = "" private var localDisplayName: String = "" + + /** Display name of the local participant (stamped on items this device adds). */ + val participantDisplayName: String + get() = localDisplayName + private var hostBroadcastJob: Job? = null /** Guest side: last queue snapshot applied to the local player. */ private var lastAppliedItems: List = emptyList() private var lastAppliedIndex = -1 + /** + * Host side: a song completed, so index changes within this window are + * auto-advance (guests must not follow). The completion event, the player + * state change and the media transition arrive as separate flow emissions, + * so the window covers the whole sequence instead of a single flag. + */ + private var autoAdvanceDeadlineMs = 0L + + /** Guest side: playback has started at least once (local control is the guest's own). */ + private var hasStartedPlayback = false + /** Host side: client ids banned for this session. */ private val bannedClientIds = mutableSetOf() @@ -110,6 +134,20 @@ class JamRoomService( jamClient.presence .onEach { onPresence(it) } .launchIn(scope) + // A naturally-completed song means the host's next index change is an + // auto-advance — guests must NOT follow those, only manual skips. + audioPlayer.completionFlow + .onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS } + .launchIn(scope) + // Once a guest has played on its own (or was started by the host), its + // play/pause is its own — the host's play state only starts fresh joiners. + audioPlayer.playerStateFlow + .onEach { state -> + if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) { + hasStartedPlayback = true + } + } + .launchIn(scope) } // ---------- Session lifecycle ---------- @@ -138,6 +176,8 @@ class JamRoomService( lastAppliedIndex = -1 bannedClientIds.clear() leaving = false + autoAdvanceDeadlineMs = 0L + hasStartedPlayback = false startHostBroadcast() persistLastCode(code) code @@ -170,6 +210,7 @@ class JamRoomService( lastAppliedItems = emptyList() lastAppliedIndex = -1 leaving = false + hasStartedPlayback = false persistLastCode(normalized) } } @@ -187,6 +228,7 @@ class JamRoomService( lastAppliedItems = emptyList() lastAppliedIndex = -1 bannedClientIds.clear() + hasStartedPlayback = false } // ---------- Controls (called from the UI) ---------- @@ -250,20 +292,25 @@ class JamRoomService( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, audioPlayer.shuffleModeFlow, - ) { queue, current, shuffle -> Triple(queue, current, shuffle) } - .onEach { (queue, current, shuffle) -> + audioPlayer.playerStateFlow, + ) { queue, current, shuffle, playerState -> + PlaybackBroadcast(queue, current, shuffle, playerState) + } + .onEach { broadcast -> if (_role.value != JamRole.Host) return@onEach - val index = if (current != null) { - queue.indexOfFirst { it.matchesEntry(current) } + val index = if (broadcast.current != null) { + broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) } } else { -1 } - _shuffleEnabled.value = shuffle + _shuffleEnabled.value = broadcast.shuffle jamClient.publishState( JamMessage.QueueState( - items = queue.map(JamMediaItem::fromQueueEntry), + items = broadcast.queue.map(JamMediaItem::fromQueueEntry), currentIndex = index.coerceAtLeast(0), - shuffleEnabled = shuffle, + shuffleEnabled = broadcast.shuffle, + follow = now() > autoAdvanceDeadlineMs, + isPlaying = broadcast.playerState == PlayerState.PLAYING, ) ) } @@ -285,25 +332,54 @@ class JamRoomService( _shuffleEnabled.value = state.shuffleEnabled runCatching { audioPlayer.shuffle(state.shuffleEnabled) } + // Late joiner: the host is already playing, so start immediately. Once + // this guest has played on its own, the host's play state is ignored. + if (state.isPlaying && !hasStartedPlayback) { + runCatching { audioPlayer.play() } + .onFailure { log.w(it) { "Failed to start playback on host play state" } } + } + val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING if (items != lastAppliedItems) { + val previous = lastAppliedItems lastAppliedItems = items - lastAppliedIndex = state.currentIndex + val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value + val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) } + + // The host only appended items (e.g. accepted suggestions): merge + // them in without resetting playback or the guest's position. + if (previous.isNotEmpty() && items.size > previous.size && + items.take(previous.size) == previous && guestIndex >= 0 + ) { + lastAppliedIndex = guestIndex + val appended = items.drop(previous.size) + runCatching { + audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() }) + }.onFailure { log.w(it) { "Failed to append jam queue items" } } + return + } + + // Full re-sync. Keep the guest's current track when it still exists + // in the synced queue; otherwise take the host's position. + val startIndex = if (guestIndex >= 0) guestIndex + else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)) + lastAppliedIndex = startIndex runCatching { audioPlayerQueue.load( entries = items.map { it.toQueueEntry() }, autoPlay = wasPlaying, - startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), + startPosition = startIndex, ) }.onFailure { log.w(it) { "Failed to apply jam queue" } } return } - if (state.currentIndex != lastAppliedIndex) { + // Same queue content: only follow the host when it moved manually + // (skip/jump). Natural auto-advance keeps everyone where they are. + if (state.follow && state.currentIndex != lastAppliedIndex) { lastAppliedIndex = state.currentIndex - // Queue moved on: follow it, but keep this device's play/pause state. runCatching { audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) }.onFailure { log.w(it) { "Failed to follow jam queue index" } } @@ -459,6 +535,17 @@ class JamRoomService( ) } + private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { + if (entry == null) return false + return when (entry) { + is QueueEntry.StreamingTrack -> + trackId.isNotBlank() && entry.track.id == trackId + + is QueueEntry.LocalTrack -> + url.isNotBlank() && entry.url == url && entry.name == title + } + } + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when { this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> this.track.id == other.track.id @@ -469,7 +556,10 @@ class JamRoomService( else -> false } + private fun now(): Long = Clock.System.now().toEpochMilliseconds() + companion object { private const val HOST_TAKEOVER_DELAY_MS = 1_500L + private const val AUTO_ADVANCE_WINDOW_MS = 2_000L } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 7857b1f7..2367ae1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -164,52 +164,56 @@ class RemotePlaybackController( _pendingRequest.value = null } + // ---------- Jam actions ---------- + /** - * Routes the pending request into the active jam session. On the host the jam - * queue IS the local queue, so the action runs locally; on a guest the content - * is suggested to the host, which accepts it into the shared queue. + * Adds a single track to the active jam queue. The host applies it to the + * local (shared) queue directly; a guest suggests it to the host over MQTT. */ - fun playOnJam() { - val request = _pendingRequest.value ?: return - _pendingRequest.value = null + fun addTrackToJam(track: MetadataTrack) { + if (jamRoomService.role.value == null) return scope.launch { try { when (jamRoomService.role.value) { - JamRole.Host -> executeLocally(request) - JamRole.Guest -> suggestToJam(request) + JamRole.Host -> audioPlayerQueue.addToQueue( + QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName) + ) + + JamRole.Guest -> jamRoomService.suggestTrack(track) null -> return@launch } - _events.emit(confirmationMessage(request)) + _events.emit("Added to the jam queue") } catch (e: Exception) { - logger.e(e) { "Failed to send content to jam session" } + logger.e(e) { "Failed to add track to jam session" } } } } - private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) { - PlaybackDestinationAction.Play -> "Playing on the jam queue" - PlaybackDestinationAction.AddToQueue -> "Added to the jam queue" - PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue" - } + /** + * Adds multiple tracks to the active jam queue (host applies locally, + * guest suggests to the host). + */ + fun addTracksToJam(tracks: List) { + if (tracks.isEmpty() || jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addAllToQueue( + tracks.map { track -> + QueueEntry.StreamingTrack( + track = track, + url = "", + addedBy = jamRoomService.participantDisplayName, + ) + } + ) - private suspend fun suggestToJam(request: PlaybackDestinationRequest) { - when (request) { - is PlaybackDestinationRequest.Collection -> { - val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id) - if (tracks.isNotEmpty()) { - jamRoomService.suggestPlaylist(tracks) - logger.i { "Suggested ${tracks.size} track(s) to the jam session" } - } - } - - is PlaybackDestinationRequest.Track -> { - jamRoomService.suggestTrack(request.track) - } - - is PlaybackDestinationRequest.Tracks -> { - if (request.tracks.isNotEmpty()) { - jamRoomService.suggestPlaylist(request.tracks) + JamRole.Guest -> jamRoomService.suggestPlaylist(tracks) + null -> return@launch } + _events.emit("Added ${tracks.size} to the jam queue") + } catch (e: Exception) { + logger.e(e) { "Failed to add tracks to jam session" } } } } @@ -217,9 +221,7 @@ class RemotePlaybackController( // ---------- Internals ---------- private fun request(request: PlaybackDestinationRequest) { - // The picker offers "This Device", a connected remote device, and an - // active jam session — show it whenever more than one destination exists. - if (isRemoteConnected() || jamRoomService.role.value != null) { + if (isRemoteConnected()) { _pendingRequest.value = request } else { executeLocally(request) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 02082d96..44ef5360 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -87,6 +87,8 @@ fun CollectionView( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, trailingContent: @Composable () -> Unit = {}, @@ -200,6 +202,8 @@ fun CollectionView( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, trackOptionsState = trackOptionsState, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 8c75b3f1..4873cb30 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -143,6 +143,8 @@ fun TrackList( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, currentTrackId: String? = null, isCurrentTrackPlaying: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, @@ -274,6 +276,7 @@ fun TrackList( ) }, trackOptionsState = trackOptionsState(track), + isInJam = isInJam, onShowOptionsClick = { selectedTrackForOptions = track }, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, @@ -442,7 +445,17 @@ fun TrackList( label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", onClick = { onBulkAddToPlaylist(targetTracks) }, ), - ), + ) + if (isInJam) { + listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", + onClick = { onBulkAddToJam(targetTracks) }, + ), + ) + } else { + emptyList() + }, trigger = { onClick -> GroupIconButton( onClick = onClick, @@ -497,6 +510,7 @@ fun TrackList( selectedTrackForOptions = null }, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, ) } } @@ -555,6 +569,7 @@ private fun TrackListRow( onSelectionToggle: (Boolean) -> Unit, onTrackOptionsAction: (TrackOptionsAction) -> Unit, trackOptionsState: TrackOptionsState, + isInJam: Boolean, onShowOptionsClick: () -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, @@ -750,6 +765,7 @@ private fun TrackListRow( state = trackOptionsState, onAction = onTrackOptionsAction, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, ) } else { GhostIconButton(onClick = onShowOptionsClick) { @@ -843,6 +859,7 @@ private fun ShimmerTrackListRow( onSelectionToggle = {}, onTrackOptionsAction = {}, trackOptionsState = TrackOptionsState(), + isInJam = false, onShowOptionsClick = {}, onArtistClick = {}, onAlbumClick = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index c401e631..e0cf1874 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxShare sealed interface TrackOptionsAction { + data object AddToJam : TrackOptionsAction data object StartRadio : TrackOptionsAction data object PlayNext : TrackOptionsAction data object AddToQueue : TrackOptionsAction @@ -98,6 +99,7 @@ fun TrackOptions( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, modifier: Modifier = Modifier, + isInJam: Boolean = false, ) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( @@ -105,6 +107,7 @@ fun TrackOptions( state = state, onAction = onAction, onAlbumClick = onAlbumClick, + isInJam = isInJam, ), trigger = { onClick -> GhostIconButton(onClick = onClick) { @@ -129,6 +132,7 @@ fun TrackOptionsBottomSheet( onDismiss: () -> Unit, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, + isInJam: Boolean = false, ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.fillMaxWidth()) { @@ -151,6 +155,7 @@ fun TrackOptionsBottomSheet( onAlbumClick() onDismiss() }, + isInJam = isInJam, ).forEach { item -> Row( modifier = Modifier @@ -241,7 +246,18 @@ private fun buildTrackMenuItems( state: TrackOptionsState, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, + isInJam: Boolean = false, ): List = buildList { + if (isInJam) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to Jam", + onClick = { onAction(TrackOptionsAction.AddToJam) }, + ), + ) + } + add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicCircle, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 8a91a2c1..dbd7da14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -20,10 +20,13 @@ package dev.krtirtho.spotube.modules.album import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -37,6 +40,9 @@ fun AlbumScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() @@ -92,6 +98,8 @@ fun AlbumScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index eb2b2ee0..3da95baa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -273,6 +273,10 @@ class AlbumViewModel( remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> @@ -333,6 +337,10 @@ class AlbumViewModel( tracks.forEach { track -> downloadManager.enqueue(track) } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" remotePlaybackController.requestTracksAddToQueue(tracks, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 5b76c144..b0fcdbf7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -63,8 +63,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton @@ -95,6 +97,9 @@ fun ArtistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() @@ -179,6 +184,8 @@ fun ArtistScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index 2baa9e9d..8c89001a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -276,6 +276,10 @@ class ArtistViewModel( startTrack = track, ) } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" remotePlaybackController.requestTracksAddToQueue(tracks, artistName) @@ -306,6 +310,10 @@ class ArtistViewModel( is TrackOptionsAction.AddToQueue -> { remotePlaybackController.requestTrackAddToQueue(track) } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 906c1492..a8b8213e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient @@ -40,8 +39,6 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist -import kotlinx.coroutines.flow.map import org.koin.compose.koinInject /** @@ -53,11 +50,8 @@ import org.koin.compose.koinInject fun PlayDestinationPickerHost() { val controller = koinInject() val remoteControlClient = koinInject() - val jamRoomService = koinInject() val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - val jamActive by jamRoomService.role.map { it != null } - .collectAsStateWithLifecycle(initialValue = false) val pendingRequest = request ?: return @@ -143,33 +137,6 @@ fun PlayDestinationPickerHost() { }, ) } - - if (jamActive) { - ListRowTile( - onClick = controller::playOnJam, - modifier = Modifier.fillMaxWidth(), - leading = { - Icon( - imageVector = Iconsax.IconsaxMusicPlaylist, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - title = { - Text( - text = "Jam Session", - style = MaterialTheme.typography.bodyLarge, - ) - }, - subtitle = { - Text( - text = "$actionLabel in the shared jam queue", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } } }, actions = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..09dc458b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -31,10 +31,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView @@ -53,6 +56,9 @@ fun PlaylistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() @@ -127,6 +133,8 @@ fun PlaylistScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, footerContent = footerContent, trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 8380e8af..d1ef1088 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -306,6 +306,10 @@ class PlaylistViewModel( remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> @@ -378,6 +382,10 @@ class PlaylistViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" remotePlaybackController.requestTracksAddToQueue(tracks, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 045a0380..4fa10f2a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -20,11 +20,14 @@ package dev.krtirtho.spotube.modules.saved_tracks import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -39,6 +42,9 @@ fun SavedTracksScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() @@ -85,6 +91,8 @@ fun SavedTracksScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 72d52113..1025bdbd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -248,6 +248,10 @@ class SavedTracksViewModel( remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> @@ -312,6 +316,10 @@ class SavedTracksViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index b8f699d8..35c50407 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField @@ -128,6 +130,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val blacklistRepository: BlacklistRepository = koinInject() val navigationCommands: NavigationCommands = koinInject() val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() @@ -188,6 +193,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.AddToQueue -> { remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() @@ -248,6 +256,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") } + fun bulkAddToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun bulkPlayNext(tracks: List) { remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } @@ -327,6 +339,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -356,6 +370,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -637,6 +653,8 @@ private fun SearchAllTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -694,6 +712,8 @@ private fun SearchAllTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, @@ -781,6 +801,8 @@ private fun SearchTracksTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -810,6 +832,8 @@ private fun SearchTracksTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index c43ef433..96172f80 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -82,6 +82,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.BaseUITheme @@ -117,6 +119,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -158,6 +161,10 @@ fun AppExpandedPlayer( ), ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val downloadsViewModel: DownloadsViewModel = koinViewModel() val navigationCommands: NavigationCommands = koinInject() @@ -198,18 +205,22 @@ fun AppExpandedPlayer( } fun onSkipPrevious() { + if (isJamGuest) return scope.launch { audioPlayer.skipToPrevious() } } fun onSkipNext() { + if (isJamGuest) return scope.launch { audioPlayer.skipToNext() } } fun onShuffleToggle() { + if (isJamGuest) return scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } } fun onLoopToggle() { + if (isJamGuest) return scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } } @@ -517,7 +528,7 @@ fun AppExpandedPlayer( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - GhostIconButton(onClick = ::onShuffleToggle) { + GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) { Icon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", @@ -528,7 +539,7 @@ fun AppExpandedPlayer( } ) } - GhostIconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } IconButton( @@ -543,10 +554,10 @@ fun AppExpandedPlayer( modifier = Modifier.size(30.dp), ) } - GhostIconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } - GhostIconButton(onClick = ::onLoopToggle) { + GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) { Icon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 9d7ffbd1..a6e09477 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -65,6 +65,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider @@ -93,6 +95,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -125,6 +128,10 @@ fun AppLargePlayer( ), ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() var isSeeking by remember { mutableStateOf(false) } @@ -155,18 +162,22 @@ fun AppLargePlayer( } fun onSkipPrevious() { + if (isJamGuest) return scope.launch { audioPlayer.skipToPrevious() } } fun onSkipNext() { + if (isJamGuest) return scope.launch { audioPlayer.skipToNext() } } fun onShuffleToggle() { + if (isJamGuest) return scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } } fun onLoopToggle() { + if (isJamGuest) return scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } } @@ -294,6 +305,7 @@ fun AppLargePlayer( ) { VariableIconButton( onClick = ::onShuffleToggle, + enabled = !isJamGuest, variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost ) { Icon( @@ -306,7 +318,7 @@ fun AppLargePlayer( } ) } - GhostIconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } IconButton( @@ -320,11 +332,12 @@ fun AppLargePlayer( contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", ) } - GhostIconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } VariableIconButton( onClick = ::onLoopToggle, + enabled = !isJamGuest, variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline ) { Icon( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 997da78f..0628e243 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -78,12 +78,13 @@ fun PlayerQueueContent( val displayItems = state.displayItems val filterQuery = state.filterQuery val isFiltered = state.isFiltered + val isReadOnly = state.isReadOnly val lazyListState = rememberLazyListState() val reorderableLazyListState = rememberReorderableLazyListState( lazyListState, onMove = { from, to -> - if (isFiltered) return@rememberReorderableLazyListState + if (isFiltered || isReadOnly) return@rememberReorderableLazyListState viewModel.onMove(from.index, to.index) }, ) @@ -118,11 +119,13 @@ fun PlayerQueueContent( singleLine = true, modifier = Modifier.weight(1f), ) - IconButton( - onClick = viewModel::clearQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), - ) { - Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + if (!isReadOnly) { + IconButton( + onClick = viewModel::clearQueue, + theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), + ) { + Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + } } } @@ -144,11 +147,12 @@ fun PlayerQueueContent( val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) QueueItemRow( item = item, - reorderScope = if (isFiltered) null else this, + reorderScope = if (isFiltered || isReadOnly) null else this, onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onDragStarted = { viewModel.onDragStart() }, onDragStopped = { viewModel.onDragStop() }, + showOptions = !isReadOnly, ) } } @@ -167,6 +171,7 @@ private fun QueueItemRow( onRemoveClick: () -> Unit, onDragStarted: () -> Unit, onDragStopped: () -> Unit, + showOptions: Boolean = true, ) { var showMenu by remember { mutableStateOf(false) } @@ -255,31 +260,33 @@ private fun QueueItemRow( Spacer(modifier = Modifier.width(4.dp)) - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), - ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) + if (showOptions) { + Box { + GhostIconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Remove from queue") }, + onClick = { + onRemoveClick() + showMenu = false + }, + leadingIcon = { + Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) + }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index c9d1a870..ced76d8e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -44,10 +46,13 @@ data class QueueContentUiState( val filterQuery: String = "", val displayItems: List = emptyList(), val isFiltered: Boolean = false, + /** Guests cannot reorder/remove/clear the shared jam queue. */ + val isReadOnly: Boolean = false, ) class PlayerQueueContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, + private val jamRoomService: JamRoomService, ) : ViewModel() { private val queueVisibilityFlow = MutableStateFlow(false) private val queueFilterFlow = MutableStateFlow("") @@ -114,7 +119,8 @@ class PlayerQueueContentViewModel( computedItems, reorderBuffer, queueFilterFlow, - ) { items, buffer, filterQuery -> + jamRoomService.role, + ) { items, buffer, filterQuery, role -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { @@ -129,6 +135,7 @@ class PlayerQueueContentViewModel( filterQuery = filterQuery, displayItems = buffer ?: filtered, isFiltered = isFiltered, + isReadOnly = role == JamRole.Guest, ) }.stateIn( scope = viewModelScope, @@ -156,7 +163,7 @@ class PlayerQueueContentViewModel( } fun removeQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { val currentQueue = audioPlayerQueue.queueFlow.value if (index < currentQueue.size) { @@ -167,12 +174,14 @@ class PlayerQueueContentViewModel( fun moveQueueItem(fromIndex: Int, toIndex: Int) { if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.move(fromIndex, toIndex) } } fun clearQueue() { + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.clear() } @@ -180,11 +189,13 @@ class PlayerQueueContentViewModel( fun onDragStart() { if (reorderBuffer.value != null) return + if (queueContentUiState.value.isReadOnly) return val currentItems = queueContentUiState.value.displayItems reorderBuffer.value = currentItems.toList() } fun onMove(from: Int, to: Int) { + if (queueContentUiState.value.isReadOnly) return val buffer = reorderBuffer.value ?: return if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return val item = buffer[from] From 4c724a333b327f7cce5be2bcf407e6275beee56d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Sep 2026 00:01:34 +0600 Subject: [PATCH 015/132] feat(jam-session): enhance guest functionality with track addition and participant moderation options --- .../spotube/core/jam/JamRoomService.kt | 18 ++- .../core/remote/RemotePlaybackController.kt | 4 +- .../krtirtho/spotube/core/ui/base/Buttons.kt | 16 +- .../core/ui/component/CollectionDetails.kt | 87 ++++++---- .../core/ui/component/CollectionView.kt | 9 +- .../spotube/core/ui/component/TrackList.kt | 62 +++++--- .../spotube/core/ui/component/TrackOptions.kt | 75 +++++---- .../spotube/modules/album/AlbumScreen.kt | 5 + .../spotube/modules/artist/ArtistScreen.kt | 4 + .../modules/playlist/PlaylistScreen.kt | 5 + .../modules/saved_tracks/SavedTracksScreen.kt | 5 + .../spotube/modules/search/SearchScreen.kt | 9 ++ .../spotube/modules/shell/AppShell.kt | 16 +- .../shell/player_queue/PlayerQueueContent.kt | 150 ++++++++++++++++++ .../PlayerQueueContentViewModel.kt | 43 ++++- .../modules/shell/player_queue/QueueSheet.kt | 7 +- 16 files changed, 406 insertions(+), 109 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index d9618e3c..f8c15a2b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -92,10 +92,14 @@ class JamRoomService( private var localClientId: String = "" private var localDisplayName: String = "" - /** Display name of the local participant (stamped on items this device adds). */ + /** Display name of the local participant. */ val participantDisplayName: String get() = localDisplayName + /** Stable id of the local participant (stamped on items this device adds). */ + val participantClientId: String + get() = localClientId + private var hostBroadcastJob: Job? = null /** Guest side: last queue snapshot applied to the local player. */ @@ -257,7 +261,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestTrack( mediaItem = JamMediaItem.fromTrack(track), - addedBy = localDisplayName, + addedBy = localClientId, ) ) } @@ -267,7 +271,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestPlaylist( tracks = tracks.map(JamMediaItem::fromTrack), - addedBy = localDisplayName, + addedBy = localClientId, ) ) } @@ -396,11 +400,15 @@ class JamRoomService( } is JamMessage.SuggestTrack -> { - if (_role.value == JamRole.Host) acceptSuggestion(listOf(message.mediaItem)) + if (_role.value == JamRole.Host) { + acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy))) + } } is JamMessage.SuggestPlaylist -> { - if (_role.value == JamRole.Host) acceptSuggestion(message.tracks) + if (_role.value == JamRole.Host) { + acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) }) + } } is JamMessage.Kick -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 2367ae1a..40602ea9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -176,7 +176,7 @@ class RemotePlaybackController( try { when (jamRoomService.role.value) { JamRole.Host -> audioPlayerQueue.addToQueue( - QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName) + QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId) ) JamRole.Guest -> jamRoomService.suggestTrack(track) @@ -203,7 +203,7 @@ class RemotePlaybackController( QueueEntry.StreamingTrack( track = track, url = "", - addedBy = jamRoomService.participantDisplayName, + addedBy = jamRoomService.participantClientId, ) } ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index edc24ebb..885f6357 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.shadow @@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.User private val BadgeShape = RoundedCornerShape(11.dp) +private const val DisabledContentAlpha = 0.38f + private val ButtonMinHeight = 40.dp private val SquareButtonSize = 40.dp @@ -178,6 +182,7 @@ fun OutlineButton( contentAlignment = Alignment.Center, ) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = { @@ -226,6 +231,7 @@ fun PrimaryButton( ) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = content, @@ -271,6 +277,7 @@ fun SecondaryButton( ) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = content, @@ -488,7 +495,14 @@ fun GroupIconButton( ), contentAlignment = Alignment.Center, ) { - content() + Box( + modifier = Modifier + .fillMaxSize() + .alpha(if (enabled) 1f else DisabledContentAlpha), + contentAlignment = Alignment.Center, + ) { + content() + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 7f4335b5..4b9ec5a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -78,6 +78,9 @@ fun CollectionDetails( onShufflePlay: () -> Unit, onAddToQueue: () -> Unit, isPlaying: Boolean = false, + /** Guest in a jam session: play/shuffle are replaced by Add to Jam. */ + isJamGuest: Boolean = false, + onAddToJam: (() -> Unit)? = null, isFollowing: Boolean = false, onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, @@ -91,39 +94,61 @@ fun CollectionDetails( val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val playPauseButton = @Composable { - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val modifier = if (isCompact) { - Modifier.weight(1f) - } else { - Modifier + if (isJamGuest) { + if (onAddToJam != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PrimaryButton( + modifier = if (isCompact) Modifier.weight(1f) else Modifier, + onClick = onAddToJam!!, + ) { + Icon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = "Add to Jam", + ) + TextWithShimmer( + text = "Add to Jam", + modifier = Modifier.padding(start = 6.dp), + ) + } + } } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val modifier = if (isCompact) { + Modifier.weight(1f) + } else { + Modifier + } - PrimaryButton( - modifier = modifier, - onClick = onPlay, - ) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - TextWithShimmer( - text = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(start = 6.dp), - ) - } - OutlineButton( - modifier = modifier, - onClick = onShufflePlay - ) { - Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer( - text = "Shuffle", - modifier = Modifier.padding(start = 6.dp), - ) + PrimaryButton( + modifier = modifier, + onClick = onPlay, + ) { + Icon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) "Pause" else "Play", + ) + TextWithShimmer( + text = if (isPlaying) "Pause" else "Play", + modifier = Modifier.padding(start = 6.dp), + ) + } + OutlineButton( + modifier = modifier, + onClick = onShufflePlay + ) { + Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") + TextWithShimmer( + text = "Shuffle", + modifier = Modifier.padding(start = 6.dp), + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 44ef5360..e4c9dd31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -89,6 +89,8 @@ fun CollectionView( onBulkAddToPlaylist: (List) -> Unit = {}, onBulkAddToJam: (List) -> Unit = {}, isInJam: Boolean = false, + isJamGuest: Boolean = false, + onAddToJam: (List) -> Unit = {}, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, trailingContent: @Composable () -> Unit = {}, @@ -116,7 +118,7 @@ fun CollectionView( } else { {} }, - actions = if (isCollapsed) { + actions = if (isCollapsed && !isJamGuest) { { IconButton(onClick = onPlay) { Icon( @@ -161,6 +163,8 @@ fun CollectionView( showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }, ) } } else { @@ -181,6 +185,8 @@ fun CollectionView( showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }, ) } }, @@ -204,6 +210,7 @@ fun CollectionView( onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, + isJamGuest = isJamGuest, trackOptionsState = trackOptionsState, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 4873cb30..a985983a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -145,6 +145,7 @@ fun TrackList( onBulkAddToPlaylist: (List) -> Unit = {}, onBulkAddToJam: (List) -> Unit = {}, isInJam: Boolean = false, + isJamGuest: Boolean = false, currentTrackId: String? = null, isCurrentTrackPlaying: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, @@ -249,6 +250,8 @@ fun TrackList( } else { selectedTrackIds + track.id } + } else if (isJamGuest) { + onTrackOptionsAction(track, TrackOptionsAction.AddToJam) } else { onTrackClick(track) } @@ -277,6 +280,7 @@ fun TrackList( }, trackOptionsState = trackOptionsState(track), isInJam = isInJam, + isJamGuest = isJamGuest, onShowOptionsClick = { selectedTrackForOptions = track }, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, @@ -424,28 +428,38 @@ fun TrackList( val isAll = selectedTrackIds.isEmpty() || trackCount == visibleTracks.size AdaptiveDropdownBottomSheet( - items = listOf( - AdaptiveMenuItem( - icon = Iconsax.IconsaxDirectboxReceive, - label = if (isAll) "Download All" else "Download $trackCount", - onClick = { onBulkDownload(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicPlaylist, - label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", - onClick = { onBulkAddToPlaylist(targetTracks) }, - ), - ) + if (isInJam) { + items = buildList { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxDirectboxReceive, + label = if (isAll) "Download All" else "Download $trackCount", + onClick = { onBulkDownload(targetTracks) }, + ), + ) + if (!isJamGuest) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", + onClick = { onBulkAddToQueue(targetTracks) }, + ), + ) + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = if (isAll) "Play All Next" else "Play $trackCount Next", + onClick = { onBulkPlayNext(targetTracks) }, + ), + ) + } + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", + onClick = { onBulkAddToPlaylist(targetTracks) }, + ), + ) + } + if (isInJam) { listOf( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, @@ -511,6 +525,7 @@ fun TrackList( }, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, isInJam = isInJam, + isJamGuest = isJamGuest, ) } } @@ -570,6 +585,7 @@ private fun TrackListRow( onTrackOptionsAction: (TrackOptionsAction) -> Unit, trackOptionsState: TrackOptionsState, isInJam: Boolean, + isJamGuest: Boolean, onShowOptionsClick: () -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, @@ -766,6 +782,7 @@ private fun TrackListRow( onAction = onTrackOptionsAction, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, isInJam = isInJam, + isJamGuest = isJamGuest, ) } else { GhostIconButton(onClick = onShowOptionsClick) { @@ -860,6 +877,7 @@ private fun ShimmerTrackListRow( onTrackOptionsAction = {}, trackOptionsState = TrackOptionsState(), isInJam = false, + isJamGuest = false, onShowOptionsClick = {}, onArtistClick = {}, onAlbumClick = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index e0cf1874..bccc4fb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -100,6 +100,7 @@ fun TrackOptions( onAlbumClick: () -> Unit, modifier: Modifier = Modifier, isInJam: Boolean = false, + isJamGuest: Boolean = false, ) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( @@ -108,6 +109,7 @@ fun TrackOptions( onAction = onAction, onAlbumClick = onAlbumClick, isInJam = isInJam, + isJamGuest = isJamGuest, ), trigger = { onClick -> GhostIconButton(onClick = onClick) { @@ -133,6 +135,7 @@ fun TrackOptionsBottomSheet( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, + isJamGuest: Boolean = false, ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.fillMaxWidth()) { @@ -156,6 +159,7 @@ fun TrackOptionsBottomSheet( onDismiss() }, isInJam = isInJam, + isJamGuest = isJamGuest, ).forEach { item -> Row( modifier = Modifier @@ -247,6 +251,7 @@ private fun buildTrackMenuItems( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, + isJamGuest: Boolean = false, ): List = buildList { if (isInJam) { add( @@ -266,40 +271,44 @@ private fun buildTrackMenuItems( ), ) - if (!state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Play next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } else if (state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Move to next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } + // A guest's queue is the shared jam queue — mutating it locally is not + // allowed, so queue actions are replaced by "Add to Jam". + if (!isJamGuest) { + if (!state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = "Play next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } else if (state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = "Move to next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } - if (!state.isInQueue) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = "Add to queue", - onClick = { onAction(TrackOptionsAction.AddToQueue) }, - ), - ) - } else { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicSquareRemove, - label = "Remove from queue", - onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, - ), - ) + if (!state.isInQueue) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to queue", + onClick = { onAction(TrackOptionsAction.AddToQueue) }, + ), + ) + } else { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicSquareRemove, + label = "Remove from queue", + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, + ), + ) + } } add( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index dbd7da14..db76747f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -25,6 +25,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes @@ -43,6 +44,8 @@ fun AlbumScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() @@ -100,6 +103,8 @@ fun AlbumScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index b0fcdbf7..a38c36e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -63,6 +63,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes @@ -100,6 +101,8 @@ fun ArtistScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() @@ -186,6 +189,7 @@ fun ArtistScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 09dc458b..b48f1253 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -36,6 +36,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes @@ -59,6 +60,8 @@ fun PlaylistScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() @@ -135,6 +138,8 @@ fun PlaylistScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, footerContent = footerContent, trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 4fa10f2a..191923a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes @@ -45,6 +46,8 @@ fun SavedTracksScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() @@ -93,6 +96,8 @@ fun SavedTracksScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 35c50407..2b2bb588 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.remote.RemotePlaybackController @@ -133,6 +134,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() @@ -341,6 +344,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { }, onBulkAddToJam = ::bulkAddToJam, isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -372,6 +376,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { }, onBulkAddToJam = ::bulkAddToJam, isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -655,6 +660,7 @@ private fun SearchAllTab( onBulkAddToPlaylist: (List) -> Unit, onBulkAddToJam: (List) -> Unit, isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -714,6 +720,7 @@ private fun SearchAllTab( onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, @@ -803,6 +810,7 @@ private fun SearchTracksTab( onBulkAddToPlaylist: (List) -> Unit, onBulkAddToJam: (List) -> Unit, isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -834,6 +842,7 @@ private fun SearchTracksTab( onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index b81d2686..b044e3a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -133,13 +133,6 @@ fun AppShell( PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 96.dp), - ) - val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) @@ -281,6 +274,15 @@ fun AppShell( } } } + + // Drawn last so it floats above the players/sheets, just above the + // bottom overlay (large player or compact player + bottombar). + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomOverlayInset + 12.dp), + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 0628e243..0042df8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton @@ -59,12 +65,15 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.copyShape +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove import org.koin.compose.viewmodel.koinViewModel import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -79,6 +88,7 @@ fun PlayerQueueContent( val filterQuery = state.filterQuery val isFiltered = state.isFiltered val isReadOnly = state.isReadOnly + var selectedParticipant by remember { mutableStateOf(null) } val lazyListState = rememberLazyListState() val reorderableLazyListState = rememberReorderableLazyListState( @@ -153,16 +163,135 @@ fun PlayerQueueContent( onDragStarted = { viewModel.onDragStart() }, onDragStopped = { viewModel.onDragStop() }, showOptions = !isReadOnly, + enabled = !isReadOnly, + onParticipantClick = { selectedParticipant = it }, ) } } } } } + + selectedParticipant?.let { participant -> + ParticipantDialog( + participant = participant, + isJamHost = state.isJamHost, + onDismiss = { selectedParticipant = null }, + onKick = { viewModel.kickParticipant(participant.id) }, + onBan = { viewModel.banParticipant(participant.id) }, + onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }, + ) + } } } } +@Composable +private fun ParticipantDialog( + participant: JamParticipant, + isJamHost: Boolean, + onDismiss: () -> Unit, + onKick: () -> Unit, + onBan: () -> Unit, + onRemoveSuggestions: () -> Unit, +) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) }, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(vertical = 8.dp), + ) { + ParticipantAvatar(participant, size = 40) + Text( + text = participant.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + if (participant.isHost) { + Text( + text = "Host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + if (isJamHost && !participant.isHost) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ListRowTile( + onClick = { + onKick() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Kick") }, + subtitle = { Text("Remove them from the session") }, + ) + ListRowTile( + onClick = { + onBan() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Ban") }, + subtitle = { Text("Kick and prevent them from rejoining") }, + ) + ListRowTile( + onClick = { + onRemoveSuggestions() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxMusicSquareRemove, + contentDescription = null, + ) + }, + title = { Text("Remove suggestions") }, + subtitle = { Text("Remove every track they added to the queue") }, + ) + } + } + } +} + +@Composable +private fun ParticipantAvatar(participant: JamParticipant, size: Int) { + Box( + modifier = Modifier + .size(size.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + @Composable private fun QueueItemRow( item: QueueItemUi, @@ -172,11 +301,14 @@ private fun QueueItemRow( onDragStarted: () -> Unit, onDragStopped: () -> Unit, showOptions: Boolean = true, + enabled: Boolean = true, + onParticipantClick: (JamParticipant) -> Unit = {}, ) { var showMenu by remember { mutableStateOf(false) } ListRowTile( onClick = onPlayClick, + enabled = enabled, selected = item.isCurrent, modifier = Modifier, leading = { @@ -260,6 +392,24 @@ private fun QueueItemRow( Spacer(modifier = Modifier.width(4.dp)) + item.addedByParticipant?.let { participant -> + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { onParticipantClick(participant) }, + contentAlignment = Alignment.Center, + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + if (showOptions) { Box { GhostIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index ced76d8e..a426a839 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow @@ -40,6 +41,8 @@ data class QueueItemUi( val isCurrent: Boolean, val imageUrl: String?, val originalIndex: Int, + /** Participant who added this item to the jam queue, if any. */ + val addedByParticipant: JamParticipant? = null, ) data class QueueContentUiState( @@ -48,6 +51,8 @@ data class QueueContentUiState( val isFiltered: Boolean = false, /** Guests cannot reorder/remove/clear the shared jam queue. */ val isReadOnly: Boolean = false, + val isJamHost: Boolean = false, + val participants: List = emptyList(), ) class PlayerQueueContentViewModel( @@ -65,7 +70,8 @@ class PlayerQueueContentViewModel( private val computedItems: StateFlow> = combine( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, - ) { queue, currentEntry -> + jamRoomService.participants, + ) { queue, currentEntry, participants -> val currentIndex = if (currentEntry != null) { queue.indexOfFirst { it.matchesCurrent(currentEntry) } } else { @@ -94,9 +100,9 @@ class PlayerQueueContentViewModel( } } - val addedBy = entry.addedBy - if (addedBy.isNotBlank()) { - subtitle = "$subtitle • Added by $addedBy" + val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy } + if (addedByParticipant != null) { + subtitle = "$subtitle • Added by ${addedByParticipant.displayName}" } QueueItemUi( @@ -107,6 +113,7 @@ class PlayerQueueContentViewModel( isCurrent = index == currentIndex, imageUrl = imageUrl, originalIndex = index, + addedByParticipant = addedByParticipant, ) } }.stateIn( @@ -120,7 +127,8 @@ class PlayerQueueContentViewModel( reorderBuffer, queueFilterFlow, jamRoomService.role, - ) { items, buffer, filterQuery, role -> + jamRoomService.participants, + ) { items, buffer, filterQuery, role, participants -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { @@ -136,6 +144,8 @@ class PlayerQueueContentViewModel( displayItems = buffer ?: filtered, isFiltered = isFiltered, isReadOnly = role == JamRole.Guest, + isJamHost = role == JamRole.Host, + participants = participants, ) }.stateIn( scope = viewModelScope, @@ -156,7 +166,7 @@ class PlayerQueueContentViewModel( } fun playQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.jumpTo(index) } @@ -187,6 +197,27 @@ class PlayerQueueContentViewModel( } } + // ---------- Jam participant moderation (host only) ---------- + + fun kickParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } + + /** Removes every queue item that the given participant suggested. */ + fun removeParticipantTracks(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { + val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId } + entries.forEach { audioPlayerQueue.removeFromQueue(it) } + } + } + fun onDragStart() { if (reorderBuffer.value != null) return if (queueContentUiState.value.isReadOnly) return diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index a0e8220c..47206bab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset private val SlidingSheetBreakpoint = 840.dp @@ -68,7 +69,11 @@ private fun SlidingQueueSheet( visible = isVisible, modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), + .padding( + top = 12.dp, + end = 12.dp, + bottom = 12.dp + LocalAppShellBottomInset.current, + ), enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), ) { From 6fabc876a547c24bc1f73ffadfbaf09bc150e98b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Sep 2026 00:04:10 +0600 Subject: [PATCH 016/132] chore: remove residual plans --- .opencode/plans/jovial-hopping-hare.md | 821 ------------------------- 1 file changed, 821 deletions(-) delete mode 100644 .opencode/plans/jovial-hopping-hare.md diff --git a/.opencode/plans/jovial-hopping-hare.md b/.opencode/plans/jovial-hopping-hare.md deleted file mode 100644 index bad5a743..00000000 --- a/.opencode/plans/jovial-hopping-hare.md +++ /dev/null @@ -1,821 +0,0 @@ -# WebRTC Support for Group Jam & Remote Control - -## Overview -Add two peer-to-peer features to Spotube: -1. **Listen Together (Group Jam)**: Multi-user synced queue over WebRTC data channels (star topology, manual SDP exchange) -2. **Remote Control**: LAN-only device control via WebSocket on the existing `LocalServer` (extended with control routes). No WebRTC needed for this feature. - -Both features share UI patterns (adaptive dialogs for play interception) but use different transport layers based on their requirements. - ---- - -## Prerequisites (One-Time Setup) - -Before starting implementation: - -1. **Initialize webrtc-rs submodule**: - ```bash - cd build/webrtc-rs && git submodule update --init --recursive - ``` - The `rtc` crate (Sans-I/O core) is a git submodule and must be initialized before building. - -2. **Verify dns-sd-kt availability**: - - Published to Maven Central: `com.appstractive:dns-sd-kt:1.1.0` - - No setup needed; just add to `libs.versions.toml` - -3. **Verify Rust toolchain**: - - Existing uniffi setup already works (discord-rpc, metadata modules) - - Ensure `cargo` is available and can build for all targets - ---- - -## Architecture Decisions (Confirmed) - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| WebRTC implementation | `webrtc-rs` via uniffi | Single codebase, identical behavior across platforms | -| Jam topology | Star (host ↔ peers) | Simpler, scales better, matches host-authority model | -| Remote Control transport | TCP/WebSocket only | LAN-only, so WebRTC is overkill; direct connection is simpler | -| Jam signaling | Manual SDP exchange | No server infrastructure needed; users copy-paste or scan QR | - ---- - -## Phase 0: Rust Uniffi WebRTC Module - -### Goal -Add `webrtc-rs` to the existing Rust crate and expose a uniffi API for WebRTC peer connections and data channels. - -### Library Details (from `build/webrtc-rs`) -- **Crate**: `webrtc` v0.21.0-beta.1 (pure Rust, no external C/C++ libs) -- **Architecture**: Sans-I/O core (`rtc` crate) + async API layer -- **Async runtime**: tokio (default) or smol -- **Crypto**: `ring` (default) or `aws-lc-rs` -- **Key types**: - - `PeerConnection` (trait) — created via `PeerConnectionBuilder::build()` - - `DataChannel` (trait) — created via `peer.create_data_channel()` - - `RTCSessionDescription` — SDP offer/answer - - `RTCIceCandidateInit` — ICE candidates - - `PeerConnectionEventHandler` (trait) — callback interface for events - - `DataChannelEvent` (enum) — polled via `dc.poll().await` -- **Event model**: PeerConnection uses callbacks; DataChannel uses polling -- **Submodule**: `rtc` git submodule must be initialized before building - -### Files to Modify -- `composeApp/Cargo.toml` — add `webrtc` dependency -- `composeApp/src/commonMain/rust/lib.rs` — register new module -- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API - -### Implementation - -1. **Initialize webrtc-rs submodule** (one-time setup): - ```bash - cd build/webrtc-rs && git submodule update --init --recursive - ``` - -2. **Add webrtc-rs dependency** to `composeApp/Cargo.toml`: - ```toml - [dependencies] - webrtc = { path = "../build/webrtc-rs", features = ["runtime-tokio", "crypto-ring"] } - tokio = { version = "1", features = ["full"] } - async-trait = "0.1" - ``` - - **Note**: Using path dependency to the local clone. For production, switch to crates.io version once stable. - -3. **Define uniffi API** in `webrtc_p2p.rs`: - - **Core objects**: - ```rust - #[uniffi::export] - pub struct PeerConnectionWrapper { - pc: Arc, - runtime: Arc, - } - - #[uniffi::export] - impl PeerConnectionWrapper { - pub async fn create_offer(&self) -> Result { - let offer = self.pc.create_offer(None).await?; - Ok(offer.sdp) - } - - pub async fn set_remote_answer(&self, answer: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::answer(answer)?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - pub async fn create_answer(&self) -> Result { - let answer = self.pc.create_answer(None).await?; - Ok(answer.sdp) - } - - pub async fn set_remote_offer(&self, offer: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::offer(offer)?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - pub async fn send_data(&self, channel: String, data: String) -> Result<(), WebrtcError> { - // Find or cache data channel by label - // ... - Ok(()) - } - - pub async fn close(&self) -> Result<(), WebrtcError> { - self.pc.close().await?; - Ok(()) - } - } - ``` - - **Callback interface for events**: - ```rust - #[uniffi::export(callback_interface)] - pub trait PeerConnectionEventHandler { - fn on_ice_candidate(&self, candidate: String); - fn on_connection_state_change(&self, state: String); - fn on_data_channel(&self, label: String); - fn on_data_channel_message(&self, label: String, data: String); - } - ``` - - **Factory function**: - ```rust - #[uniffi::export] - pub async fn create_peer_connection( - ice_servers: Vec, - handler: Arc, - ) -> Result { - // Build RTCConfiguration from ice_servers - // Create MediaEngine, Registry - // Build PeerConnection with handler wrapper - // Spawn task to poll data channel events and forward to handler - Ok(PeerConnectionWrapper { pc, runtime }) - } - ``` - - **Key challenge**: webrtc-rs is fully async, but uniffi callbacks are synchronous. Solution: - - Wrap the `PeerConnectionEventHandler` trait in a Rust adapter that spawns async tasks - - Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks - - For DataChannel polling, spawn a background task that calls `dc.poll().await` in a loop and forwards messages to the Kotlin handler - -4. **Register module** in `lib.rs`: - ```rust - mod webrtc_p2p; - pub use webrtc_p2p::*; - ``` - -5. **Cross-compilation considerations**: - - **Good news**: webrtc-rs is pure Rust (no libwebrtc/BoringSSL C++ deps) - - **Crypto**: `ring` compiles from source for all targets (requires C compiler for Android/iOS) - - **JVM desktop**: Should work out of the box - - **Android**: Requires NDK + `ring` cross-compilation setup (well-supported) - - **iOS**: Requires `ring` cross-compilation for aarch64-apple-ios - - **Gobley plugin**: Already configured for multi-target Rust builds in `composeApp/build.gradle.kts` - -### Verification -- Initialize submodule: `cd build/webrtc-rs && git submodule update --init --recursive` -- Build Rust crate: `cargo build --release` in `composeApp/` -- Verify Kotlin bindings are generated in `uniffi.compose_app.*` -- Write a simple Kotlin test that creates a peer connection and exchanges SDP - ---- - -## Phase 1: Remote Control (LAN-only, extend existing LocalServer) - -### Goal -Allow users to control playback on another device on the same LAN. Opt-in via settings. DNS-SD for discovery (via dns-sd-kt). Extend the existing `LocalServer` with WebSocket routes for control commands — no separate server needed. - -### 1.1 Settings & Permissions - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` — add fields to `UserSettings` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` — add toggle UI -- `composeApp/src/commonMain/composeResources/values/strings.xml` — add strings -- `composeApp/src/androidMain/AndroidManifest.xml` — add permissions -- `iosApp/iosApp/Info.plist` — add Bonjour services - -#### Implementation -1. **Add to `UserSettings`**: - ```kotlin - val allowRemoteControl: Boolean = false, - val allowedRemoteDevices: List = emptyList(), // device IDs - ``` - -2. **Add toggle UI** in `PlaybackSection.kt`: - - Use `SwitchSettingCard` for "Allow remote control" - - Add a "Manage allowed devices" item that navigates to a sub-screen (see `Routes.Blacklist` pattern) - -3. **Add string resources**: - ```xml - Allow Remote Control - Let other devices on your network control playback - ``` - -4. **Android permissions** (dns-sd-kt requires these): - ```xml - - - - - - - - - ``` - - **Note**: dns-sd-kt uses `androidx.startup` to auto-initialize `Context` — no manual init needed. - -5. **iOS Info.plist** (add to `iosApp/iosApp/Info.plist`): - ```xml - NSLocalNetworkUsageDescription - Spotube needs access to your local network to discover and control other devices. - NSBonjourServices - - _spotube-ctrl._tcp - - ``` - - **Note**: dns-sd-kt's Apple backend uses `NWBrowser` (Network.framework) + custom Swift bridge. The `NSBonjourServices` key is required for Bonjour discovery to work. - -### 1.2 DNS-SD Discovery - -#### Library Details (from `build/dns-sd-kt`) -- **Maven Central**: `com.appstractive:dns-sd-kt:1.1.0` -- **KMP library**: supports Android, JVM, iOS (arm64 + simulatorArm64), macOS, tvOS -- **Fully coroutine/Flow-based** — no callback-style API -- **Platform backends**: - - Android: `NsdManager` (pure Kotlin) - - JVM: `JmDNS 3.6.3` (pure Java) - - Apple: `NWBrowser` + `NSNetService` + custom Swift bridge via `spm4kmp` -- **Two-phase resolution**: `DiscoveryEvent.Discovered` → call `resolve()` → `DiscoveryEvent.Resolved` with addresses -- **Auto-init on Android**: uses `androidx.startup` to grab `Context` - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — wraps dns-sd-kt APIs - -#### Files to Modify -- `gradle/libs.versions.toml` — add dns-sd-kt dependency -- `composeApp/build.gradle.kts` — add to commonMain dependencies - -#### Implementation - -1. **Add dns-sd-kt dependency** to `gradle/libs.versions.toml`: - ```toml - [versions] - dns-sd-kt = "1.1.0" - - [libraries] - dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dns-sd-kt" } - ``` - -2. **Add to `composeApp/build.gradle.kts`** in `commonMain.dependencies`: - ```kotlin - implementation(libs.dns.sd.kt) - ``` - -3. **Create `DeviceDiscoveryService`** in `commonMain` (no expect/actual needed — dns-sd-kt handles platform differences): - ```kotlin - class DeviceDiscoveryService { - private val serviceType = "_spotube-ctrl._tcp" - - fun discoverDevices(): Flow = discoverServices(serviceType) - - suspend fun registerDevice(deviceId: String, deviceName: String, port: Int): NetService { - val service = createNetService( - type = serviceType, - name = deviceName, - port = port, - txt = mapOf("deviceId" to deviceId), - ) - service.register() - return service - } - } - ``` - -4. **Usage in ViewModel**: - ```kotlin - // Discover devices - discoveryService.discoverDevices() - .onEach { event -> - when (event) { - is DiscoveryEvent.Discovered -> { - event.resolve() // trigger address resolution - // Add to discovered devices list (addresses may be empty) - } - is DiscoveryEvent.Resolved -> { - // Update with resolved addresses/host - } - is DiscoveryEvent.Removed -> { - // Remove from list - } - } - } - .launchIn(viewModelScope) - ``` - -5. **Register in Koin** in `Modules.kt`: - ```kotlin - single { DeviceDiscoveryService() } - ``` - -6. **No expect/actual needed** — dns-sd-kt is a KMP library that handles platform differences internally. The Apple targets use Swift interop via `spm4kmp`, which is transparent to consumers. - -### 1.3 Control Server (Extend LocalServer) - -#### Decision: Reuse Existing LocalServer -The app already has a Ktor CIO-based `LocalServer` running on `127.0.0.1:` for the playback proxy. We'll extend it with WebSocket routes for control commands. When remote control is enabled, the server binds to `0.0.0.0` (LAN-accessible); otherwise it stays on `127.0.0.1` (local-only). - -#### Files to Modify -- `gradle/libs.versions.toml` — add `ktor-server-websockets`, `ktor-client-websockets` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — add WebSocket routes, conditional bind to `0.0.0.0` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt` — update LocalServer registration - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — handles control messages -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — message definitions -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — wraps AudioPlayerInterface for remote control - -#### Implementation -1. **Add WebSocket dependencies** to `libs.versions.toml`: - ```toml - ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } - ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } - ``` - -2. **Extend `LocalServer.kt`**: - - Add `RemoteControlHandler` constructor parameter - - In `configureRoutes()`, install `WebSockets` plugin and add `/control` WebSocket route - - In `restartServer()`, check `settings.allowRemoteControl`: - - If enabled: bind to `0.0.0.0` (LAN-accessible) - - If disabled: bind to `127.0.0.1` (local-only, current behavior) - - Add a watcher that restarts the server when `allowRemoteControl` setting changes - - ```kotlin - private suspend fun restartServer(port: Int) { - val allowRemoteControl = settingsViewModel.settingsState.value?.allowRemoteControl ?: false - val host = if (allowRemoteControl) "0.0.0.0" else "127.0.0.1" - - serverState.value = embeddedServer( - factory = CIO, - host = host, - port = port, - module = { configureRoutes() } - ).also { engine -> - engine.start(wait = false) - } - } - - private fun Application.configureRoutes() { - install(WebSockets) - routing { - get("/health") { call.respondText("ok") } - // ... existing routes ... - - webSocket("/control") { - remoteControlHandler.handleConnection(this) - } - } - } - ``` - -3. **Define protocol** in `RemoteControlProtocol.kt`: - ```kotlin - @Serializable - sealed class RemoteControlMessage { - @Serializable data class Play(val trackId: String) : RemoteControlMessage() - @Serializable data class Pause(val unit: Unit = Unit) : RemoteControlMessage() - @Serializable data class Seek(val positionMs: Long) : RemoteControlMessage() - @Serializable data class SetVolume(val volume: Float) : RemoteControlMessage() - @Serializable data class AddToQueue(val trackId: String) : RemoteControlMessage() - // ... etc - } - - @Serializable - sealed class RemoteStateUpdate { - @Serializable data class PlayerState(val state: PlayerUiState) : RemoteStateUpdate() - @Serializable data class QueueUpdate(val queue: List) : RemoteStateUpdate() - } - ``` - -4. **Create `RemoteControlHandler`**: - - Handles incoming WebSocket connections on the controlled device - - Checks `settings.allowRemoteControl` before accepting (rejects immediately if disabled) - - Shows connection request dialog (allow/allow-always/deny) via a callback injected from the UI layer - - On acceptance: forwards commands to `AudioPlayerInterface` and `AudioPlayerQueue` - - Broadcasts state updates (player state, queue) to the connected controller - -5. **Create `RemotePlayerProxy`**: - - Wraps `AudioPlayerInterface` and `AudioPlayerQueue` on the controlling device - - Sends commands over WebSocket to the controlled device - - Receives state updates and exposes them as StateFlows - - **Implementation note**: Full interface implementation is complex. Alternative: create a separate `RemotePlayerState` StateFlow that mirrors remote state, and the UI uses it instead of `rememberPlayerUiState()`. - -6. **Register in Koin** in `Modules.kt`: - ```kotlin - single { RemoteControlHandler(get(), get(), get()) } - // LocalServer constructor updated; no other DI changes needed - ``` - -### Key Simplification -By reusing `LocalServer`, we eliminate the need for: -- A separate WebSocket server -- Separate port management -- Duplicate Ktor configuration - -The server becomes a multi-purpose local server: playback proxy (always) + control endpoint (when enabled). - -### 1.4 UI: Devices Screen - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Devices` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Devices" button at bottom -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Devices" icon to TopAppBar actions - -#### Implementation -1. **Add route** to `NavigationModule.kt`: - ```kotlin - @Serializable - data object Devices : Routes - - navigation { - DevicesScreen(...) - } - ``` - -2. **Add sidebar button** in `AppSidebar.kt` (after line 177): - ```kotlin - SidebarItem( - title = "Devices", - icon = Icons.Default.Devices, - onClick = { navigator.navigate(Routes.Devices) } - ) - ``` - -3. **Add TopAppBar action** in `HomeScreen.kt`: - ```kotlin - ApplicationMainBar( - actions = { - IconButton(onClick = { navigator.navigate(Routes.Devices) }) { - Icon(Icons.Default.Devices, "Devices") - } - } - ) - ``` - -4. **DevicesScreen**: - - Shows list of discovered devices (from `DeviceDiscoveryService`) - - Each device shows name, IP, and connection status - - Clicking a device initiates connection (WebSocket) - - After connection, navigates to `RemotePlayerScreen` - -5. **RemotePlayerScreen**: - - Similar to `AppExpandedPlayer` but uses `RemotePlayerProxy` instead of local `AudioPlayerInterface` - - All controls (play/pause/seek/volume/queue) forward to remote device - -### 1.5 Connection Request Flow - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/remote/ConnectionRequestDialog.kt` - -#### Implementation -1. When a new device tries to connect, `RemoteControlService` shows a dialog: - ```kotlin - AdaptiveDialogBottomSheet( - title = { Text("Remote Control Request") }, - content = { - Text("Device '${deviceName}' wants to control playback") - }, - actions = { - Button(onClick = { deny() }) { Text("Deny") } - Button(onClick = { allow(always = false) }) { Text("Allow") } - Button(onClick = { allow(always = true) }) { Text("Allow Always") } - } - ) - ``` - -2. If "Allow Always", add device ID to `settings.allowedRemoteDevices` - ---- - -## Phase 2: Group Jam (WebRTC, Manual SDP) - -### Goal -Multi-user synced queue over WebRTC data channels. Host creates session, shares SDP offer (via copy-paste or QR), guests join. Star topology (host ↔ peers). - -### 2.1 Jam Session Service - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — manages WebRTC connections -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt` — message definitions -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt` — syncs queue state - -#### Implementation -1. **Define protocol** in `JamProtocol.kt`: - ```kotlin - @Serializable - sealed class JamMessage { - // Host → Peers - @Serializable data class QueueState(val queue: List, val currentIndex: Int) : JamMessage() - @Serializable data class PlaybackCommand(val command: PlaybackCommand) : JamMessage() - @Serializable data class ParticipantList(val participants: List) : JamMessage() - - // Peers → Host - @Serializable data class SuggestTrack(val trackId: String) : JamMessage() - @Serializable data class SuggestPlaylist(val playlistId: String) : JamMessage() - @Serializable data class ChatMessage(val text: String) : JamMessage() - } - - data class Participant(val id: String, val name: String, val isHost: Boolean) - ``` - -2. **Create `JamSessionService`** using the uniffi WebRTC API from Phase 0: - ```kotlin - class JamSessionService( - private val audioPlayerQueue: AudioPlayerQueue, - private val audioPlayer: AudioPlayerInterface, - ) { - private var peerConnection: PeerConnectionWrapper? = null - private val _participants = MutableStateFlow>(emptyList()) - val participants: StateFlow> = _participants - - suspend fun createSession(): String { - // Create peer connection with ICE servers - peerConnection = create_peer_connection( - iceServers = listOf("stun:stun.l.google.com:19302"), - handler = object : PeerConnectionEventHandler { - override fun on_ice_candidate(candidate: String) { - // ICE candidates are bundled into SDP (non-trickle mode) - } - override fun on_data_channel_message(label: String, data: String) { - // Parse JamMessage and handle - } - // ... other callbacks - } - ) - - // Create data channel for jam messages - // Create SDP offer and return it for sharing - val offer = peerConnection!!.create_offer() - return offer - } - - suspend fun joinSession(offer: String): String { - // Create peer connection - peerConnection = create_peer_connection(...) - - // Set remote offer and create answer - peerConnection!!.set_remote_offer(offer) - val answer = peerConnection!!.create_answer() - return answer - } - - suspend fun sendMessage(message: JamMessage) { - val json = Json.encodeToString(message) - peerConnection?.send_data("jam", json) - } - } - ``` - - **Key points**: - - Uses `uniffi.compose_app.create_peer_connection()` from Phase 0 - - Host creates multiple peer connections (one per guest) — star topology - - Data channel labeled "jam" for all jam messages - - SDP exchange is manual (copy-paste or QR code) - -3. **Create `QueueSyncManager`**: - - On host: wraps `AudioPlayerQueue`, intercepts all queue mutations, broadcasts them via `JamSessionService.sendMessage()` - - On guest: receives queue mutations, applies them to local queue - - Handles conflict resolution (host authority: host's commands always win) - -4. **Register in Koin**: - ```kotlin - single { JamSessionService(get(), get()) } - ``` - -### 2.2 Jam Session UI - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — create/join session -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — active session view -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/SdpExchangeDialog.kt` — copy-paste SDP - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Jam` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Group Jam" button -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Group Jam" icon to TopAppBar - -#### Implementation -1. **Add route**: - ```kotlin - @Serializable - data object Jam : Routes - - navigation { - JamScreen(...) - } - ``` - -2. **Add sidebar button** (above "Devices"): - ```kotlin - SidebarItem( - title = "Group Jam", - icon = Icons.Default.Group, - onClick = { navigator.navigate(Routes.Jam) } - ) - ``` - -3. **JamScreen**: - - Two tabs: "Create Session" and "Join Session" - - **Create Session**: - - Generates SDP offer via `JamSessionService` - - Shows SDP as copyable text and QR code - - Waits for guests to connect - - **Join Session**: - - Text field to paste SDP offer - - QR code scanner (optional) - - Generates SDP answer and shows it for host to paste back - -4. **JamSessionScreen**: - - Shows list of participants (from `JamSessionService`) - - Shows current track and queue - - Playback controls (only work for host; guests send suggestions) - - Suggest track/playlist buttons - - Chat/messages area (optional) - -5. **SdpExchangeDialog**: - - Shows SDP string in a `TextField` (read-only for offer, editable for answer) - - "Copy" button - - "Paste" button (for answer) - - QR code display (using a QR generation library) - -### 2.3 Play Interception - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt` - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt` - -#### Implementation -1. **Create `PlayDestinationPicker`**: - ```kotlin - @Composable - fun PlayDestinationPicker( - onPlayLocally: () -> Unit, - onSuggestToJam: () -> Unit, - onDismiss: () -> Unit - ) { - AdaptiveDialogBottomSheet( - title = { Text("Play Where?") }, - content = { - Column { - Button(onClick = onPlayLocally) { Text("Play on This Device") } - Button(onClick = onSuggestToJam) { Text("Suggest to Jam Session") } - } - }, - onDismiss = onDismiss - ) - } - ``` - -2. **Modify ViewModels**: - - In `PlaylistViewModel.playPlaylist()`, check if `JamSessionService.isActive` - - If active, show `PlayDestinationPicker` instead of calling `playbackHelper.playPlaylist()` directly - - If user chooses "Suggest to Jam", call `JamSessionService.suggestPlaylist(playlistId)` - -3. **Apply same pattern** to `AlbumViewModel`, `ArtistScreen`, `SearchScreen` - ---- - -## Phase 3: Deep-Link Handling (Optional Enhancement) - -### Goal -Allow users to open `spotube://jam/` links to join a Jam session. With manual SDP exchange, the deep link can contain a session ID + a short-lived token, and the actual SDP exchange happens in the app. - -### Files to Modify -- `composeApp/src/androidMain/AndroidManifest.xml` — add intent filter -- `iosApp/iosApp/ContentView.swift` — add `onOpenURL` handler -- `composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt` — parse command-line args - -### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/DeepLinkService.kt` — expect interface -- Platform actuals - -### Implementation -1. **Android**: Add intent filter to `MainActivity`: - ```xml - - - - - - - ``` - -2. **iOS**: Add `onOpenURL` in `ContentView.swift`: - ```swift - .onOpenURL { url in - // Pass to Compose via a callback - } - ``` - -3. **Desktop**: Parse `args` in `main.kt`: - ```kotlin - fun main(args: Array) { - val deepLink = args.firstOrNull { it.startsWith("spotube://") } - // Pass to Compose - } - ``` - -4. **DeepLinkService**: Parse URL, navigate to `Routes.Jam(sessionId)` - ---- - -## Implementation Order - -1. **Phase 0**: Rust uniffi WebRTC module (foundation for Jam) -2. **Phase 1**: Remote Control (simpler, LAN-only, no WebRTC needed) - - 1.1 Settings & Permissions - - 1.2 DNS-SD Discovery (using dns-sd-kt) - - 1.3 Extend LocalServer with WebSocket control routes + conditional bind - - 1.4 UI: Devices Screen - - 1.5 Connection Request Flow -3. **Phase 2**: Group Jam (WebRTC, manual SDP) - - 2.1 Jam Session Service - - 2.2 Jam Session UI - - 2.3 Play Interception -4. **Phase 3**: Deep-Link Handling (optional, can be deferred) - ---- - -## Key Files Summary - -### Rust -- `composeApp/Cargo.toml` — add `webrtc` dependency -- `composeApp/src/commonMain/rust/lib.rs` — register `webrtc_p2p` module -- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API - -### Settings -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` - -### Remote Control -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — **NEW**: wraps dns-sd-kt -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — **NEW**: handles WebSocket control connections -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — **NEW**: message definitions -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — **NEW**: remote player state proxy -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — **MODIFIED**: add WebSocket routes, conditional bind - -### Group Jam -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — **NEW** - -### Navigation & UI -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` - -### Permissions -- `composeApp/src/androidMain/AndroidManifest.xml` -- `iosApp/iosApp/Info.plist` - ---- - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| `webrtc-rs` `rtc` submodule not initialized | Document in setup: `cd build/webrtc-rs && git submodule update --init --recursive` | -| `ring` crypto cross-compilation for Android/iOS | Well-supported; may need NDK env vars for Android. Test early. | -| webrtc-rs is pre-release (0.21.0-beta.1) | API is stabilizing; pin version. Monitor for 1.0 release. | -| dns-sd-kt Apple targets use Swift interop (`spm4kmp`) | Published Maven Central artifacts include cinterop bindings. Should work transparently. | -| Manual SDP exchange is poor UX | Add QR code scanning as an alternative (Phase 2.2) | -| Queue sync conflicts in Jam | Host authority model: host's commands always win | -| WebRTC data channel reliability | Use ordered, reliable data channels (default in webrtc-rs) | -| Uniffi async/sync bridge for webrtc-rs | Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks | - ---- - -## Testing Strategy - -1. **Unit tests**: Test protocol serialization, queue sync logic -2. **Integration tests**: Test WebSocket server/client, DNS-SD discovery -3. **Manual tests**: - - Remote Control: Two devices on same LAN, control playback from one to another - - Group Jam: Three devices (1 host + 2 guests), sync queue and playback -4. **Cross-platform tests**: Verify on Android, iOS, JVM desktop (Linux/Windows/macOS) From df9403f2d3ceb5dbcf56f9d5bbf37fdaafbbc951 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Sep 2026 10:09:03 +0600 Subject: [PATCH 017/132] feat(release): add support for Linux ARM64 builds and artifact uploads --- .github/workflows/spotube-release-binary.yml | 66 +++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index b1c07619..6df4bf5a 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -221,6 +221,67 @@ jobs: with: limit-access-to-actor: true + build-linux-arm64: + name: Linux Desktop (aarch64) + needs: prepare-deps + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + - name: Download maven local + uses: actions/download-artifact@v4 + with: + name: maven-local + path: ~/.m2/repository/ + + - name: Install packaging tools + run: | + sudo apt-get update + sudo apt-get install -y rpm fuse + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Package Linux + run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm + + - name: Rename Linux artifacts + run: | + DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1) + RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1) + cp "$DEB_FILE" Spotube-linux-aarch64.deb + cp "$RPM_FILE" Spotube-linux-aarch64.rpm + + - name: Upload DEB + uses: actions/upload-artifact@v4 + with: + name: linux-deb-arm64 + path: Spotube-linux-aarch64.deb + + - name: Upload RPM + uses: actions/upload-artifact@v4 + with: + name: linux-rpm-arm64 + path: Spotube-linux-aarch64.rpm + + - name: Setup tmate session on failure + if: ${{ inputs.debug_ssh && failure() }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + build-windows: name: Windows Desktop needs: prepare-deps @@ -321,6 +382,7 @@ jobs: needs: - build-android - build-linux + - build-linux-arm64 - build-windows - build-macos runs-on: ubuntu-latest @@ -352,7 +414,7 @@ jobs: run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md - for dir in android-apk android-aab linux-deb linux-rpm linux-appimage windows-msi windows-exe macos-dmg; do + for dir in android-apk android-aab linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 windows-msi windows-exe macos-dmg; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -376,6 +438,8 @@ jobs: linux-deb/*.deb linux-rpm/*.rpm linux-appimage/*.AppImage + linux-deb-arm64/*.deb + linux-rpm-arm64/*.rpm windows-msi/*.msi windows-exe/*.exe macos-dmg/*.dmg From 1e6aee142bda87108bdbeaac22988cc10b4d731d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 14:53:10 +0600 Subject: [PATCH 018/132] feat(player-options): add PlayerOptionsViewModel and enhance player UI with options menu --- .opencode/opencode.jsonc | 15 + .vscode/settings.json | 4 + AGENTS.md | 17 +- .../spotube/core/audioplayer/AudioPlayer.kt | 30 -- .../core/audioplayer/AudioPlayerModels.kt | 27 ++ .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../spotube/core/server/StreamProxy.kt | 7 +- .../core/server/StreamingUrlRepository.kt | 177 +++++-- .../modules/settings/sections/utils.kt | 2 +- .../modules/shell/AppExpandedPlayer.kt | 37 +- .../spotube/modules/shell/AppLargePlayer.kt | 57 ++- .../modules/shell/PlayerOptionDialogs.kt | 401 ++++++++++++++++ .../modules/shell/PlayerOptionsViewModel.kt | 241 ++++++++++ .../core/server/StreamingUrlRepositoryTest.kt | 434 ++++++++++++++++++ .../plugin_apis/audio/AudioModels.kt | 1 + .../audio/AudioQualitySerializationTest.kt | 28 ++ 16 files changed, 1382 insertions(+), 98 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt create mode 100644 plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 6dbeab07..a8304a54 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -3,6 +3,7 @@ "mcp": { "compose-hot-reload": { "enabled": true, + "disabled": false, "type": "local", "command": [ "./gradlew", @@ -11,6 +12,20 @@ "--console=plain", "hotMcpServer" ] + }, + "android-studio-mcp": { + "type": "local", + "environment": { + "IJ_MCP_SERVER_PORT": "64342" + }, + "enabled": true, + "disabled": false, + "command": [ + "/opt/android-studio/jbr/bin/java", + "-classpath", + "/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/mcpserver.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.modelcontextprotocol.kotlin.sdk.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.github.oshai.kotlin.logging.jvm.jar:/opt/android-studio/lib/util-8.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.coroutines.core.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.cio.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.jar:/opt/android-studio/lib/intellij.libraries.ktor.network.tls.jar:/opt/android-studio/lib/intellij.libraries.ktor.io.jar:/opt/android-studio/lib/intellij.libraries.ktor.utils.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.io.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.core.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.json.jar", + "com.intellij.mcpserver.stdio.McpStdioRunnerKt" + ] } } } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b2f9a94e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "gradle.autoDetect": "off", + "java.gradle.buildServer.enabled": "off" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index ee383842..7c7a90a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,4 +56,19 @@ - **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release. - **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease. - **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets. -- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. \ No newline at end of file +- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. + +## OpenCode Agent MCP Execution Directives + +### 1. Code Discovery & Context Search +- Use `android-studio-mcp` search/symbol tools to locate declarations before making edits across `:composeApp`, `:plugin_interfaces`, or `:js_plugin_example`[cite: 1, 2]. +- Keep `gradle/libs.versions.toml` as the single source of truth for dependencies and version updates. + +### 2. UI Architecture Constraints +- **Strict ViewModel Separation:** UI composables in `commonMain` must only collect `StateFlow` and emit event callbacks. Do NOT write business logic, filtering, or `mutableStateListOf` directly inside `@Composable` functions. +- **Adaptive Components:** Use `AdaptiveDropdownBottomSheet` or `AdaptiveDialogBottomSheet` inside `commonMain` for adaptive layouts[cite: 2]. Do NOT create platform-specific `expect/actual` variants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2]. + +### 3. Verification & Diagnostic Loops +- **Post-Edit Verification:** After editing Kotlin code, validate compilation by running `./gradlew :composeApp:check` or `./gradlew :composeApp:assembleDebug` via MCP[cite: 2]. +- **Dispatcher Integrity:** Never remove or bypass `SwingMainDispatcherFactory` in `composeApp/src/jvmMain/.../core/coroutines/`[cite: 2]. It is required to prevent `compose-webview` from hijacking `Dispatchers.Main` away from the Swing EDT[cite: 2]. +- **Live Preview Iteration:** Use `compose-hot-reload` to evaluate UI layout changes headlessly without forcing full Gradle rebuilds. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt index 8587e043..67cbe65f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt @@ -17,40 +17,10 @@ package dev.krtirtho.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlin.time.Duration -data class MediaItem( - val title: String, - val artist: String, - val album: String, - val duration: Duration, - val coverURL: String, - val url: String, - val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, -) { - fun toDebugString(): String { - return "MediaItem(title='$title', artist='$artist', album='$album', duration=$duration, coverURL='$coverURL', url='$url', protocol=$protocol)" - } -} - -enum class LoopState { - NONE, ONE, ALL; - - fun next(): LoopState { - return when (this) { - NONE -> ONE - ONE -> ALL - ALL -> NONE - } - } -} - -enum class PlayerState { - IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED -} interface AudioPlayerInterface { // // Playback diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt index 1562a2e1..da119020 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt @@ -21,6 +21,33 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.time.Duration + +data class MediaItem( + val title: String, + val artist: String, + val album: String, + val duration: Duration, + val coverURL: String, + val url: String, + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, +) + +enum class LoopState { + NONE, ONE, ALL; + + fun next(): LoopState { + return when (this) { + NONE -> ONE + ONE -> ALL + ALL -> NONE + } + } +} + +enum class PlayerState { + IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED +} @Serializable sealed interface QueueEntry { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index a130729f..9c9c7964 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -75,6 +75,7 @@ import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel +import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel import org.koin.core.module.Module @@ -96,6 +97,7 @@ val sharedModules = module { // Shell viewModelOf(::AppShellViewModel) + viewModelOf(::PlayerOptionsViewModel) viewModelOf(::PlayerQueueContentViewModel) viewModelOf(::AlternativeTrackContentViewModel) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt index a937bc65..ede7b89c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt @@ -53,6 +53,7 @@ import okio.SYSTEM import okio.buffer import okio.use import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.milliseconds internal class StreamProxy( private val httpClient: HttpClient, @@ -114,7 +115,7 @@ internal class StreamProxy( logger.d { "Track $trackId is being cached, waiting for cache" } var waited = 0 while (waited < 30) { - delay(200) + delay(200.milliseconds) waited++ val entry = cacheManager.findCachedEntry(trackId) if (entry != null) { @@ -245,7 +246,7 @@ internal class StreamProxy( call.respond(object : OutgoingContent.WriteChannelContent() { override val status: HttpStatusCode = HttpStatusCode.OK override val contentType: ContentType = contentType - override val contentLength: Long? = rewrittenManifest.encodeToByteArray().size.toLong() + override val contentLength: Long = rewrittenManifest.encodeToByteArray().size.toLong() override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { channel.writeFully(rewrittenManifest.encodeToByteArray()) @@ -305,7 +306,7 @@ internal class StreamProxy( var currentUrl = streamUrl var attemptedRefresh = false - var upstream: HttpResponse? = null + var upstream: HttpResponse? while (true) { val response = runCatching { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt index c3af11d4..00a215c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt @@ -18,15 +18,20 @@ package dev.krtirtho.spotube.core.server import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.plugin.PluginManager +import dev.krtirtho.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.component.KoinComponent +import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -36,6 +41,10 @@ data class CachedStreamUrl( val expiresAtMs: Long, val container: String, val codec: String, + val source: AudioSource.Streamed? = null, + val selectedStream: AudioStream? = null, + val preferredFormat: AudioFormat, + val preferredQuality: AudioQuality, ) data class StreamInfo( @@ -45,11 +54,11 @@ data class StreamInfo( val container: String, ) - class StreamingUrlRepository( private val pluginManager: PluginManager, private val audioPlayerQueue: AudioPlayerQueue, private val matchedTracksRepository: MatchedTracksRepository, + private val settingsRepository: SettingsRepository, ) : KoinComponent { companion object { @@ -62,15 +71,6 @@ class StreamingUrlRepository( private val alternativesCacheMutex = Mutex() private val alternativesCache = mutableMapOf>() - private fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { - if (url.startsWith("http")) return url - val base = when (protocol) { - StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com" - StreamProtocol.PROGRESSIVE -> return url - } - return "$base$url" - } - suspend fun resolveStreamInfo( trackId: String, forceRefresh: Boolean = false @@ -91,13 +91,16 @@ class StreamingUrlRepository( forceRefresh: Boolean = false ): StreamInfo? { val trackId = track.id + val settings = settingsRepository.userSettings.value + val preferredFormat = settings.streamingMusicFormat + val preferredQuality = settings.streamingMusicQuality + if (!forceRefresh) { - getCachedStreamUrl(trackId)?.let { cached -> + getCachedStreamUrl(trackId, preferredFormat, preferredQuality)?.let { cached -> logger.v { "Using cached stream URL for track $trackId" } return cached } } - val audioPlugin = pluginManager.selectedAudioPlugin.value if (audioPlugin == null) { logger.w { "No audio plugin selected while resolving stream for track $trackId" } @@ -120,16 +123,14 @@ class StreamingUrlRepository( if (stream != null) { logger.d { "Resolved stream for track $trackId using cached source" } - val resolvedStream = stream.streams.firstOrNull() + val resolvedStream = selectPreferredAudioStream( + stream.streams, + preferredFormat, + preferredQuality, + ) if (resolvedStream != null) { - val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) - val info = StreamInfo( - url, - resolvedStream.protocol, - resolvedStream.codec, - resolvedStream.container - ) - cacheStreamInfo(trackId, info) + val info = resolvedStream.toStreamInfo() + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) return info } } @@ -182,45 +183,66 @@ class StreamingUrlRepository( } logger.d { "Resolved stream for track $trackId via track lookup" } - val resolvedStream = stream.streams.firstOrNull() + val resolvedStream = selectPreferredAudioStream( + stream.streams, + preferredFormat, + preferredQuality, + ) if (resolvedStream != null) { - val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) - val info = StreamInfo( - url, - resolvedStream.protocol, - resolvedStream.codec, - resolvedStream.container - ) - cacheStreamInfo(trackId, info) + val info = resolvedStream.toStreamInfo() + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) return info } return null } - suspend fun getCachedStreamUrl(trackId: String): StreamInfo? { + private fun AudioStream.toStreamInfo() = StreamInfo( + url = normalizeManifestUrl(url, protocol), + protocol = protocol, + codec = codec, + container = container, + ) + + private suspend fun getCachedStreamUrl( + trackId: String, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + ): StreamInfo? { val now = Clock.System.now().toEpochMilliseconds() return streamUrlCacheMutex.withLock { val cached = streamUrlCache[trackId] ?: return@withLock null + if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) { + return@withLock null + } if (cached.expiresAtMs <= now) { - streamUrlCache.remove(trackId) logger.v { "Cached stream URL expired for track $trackId" } return@withLock null } - StreamInfo(cached.url, cached.protocol, cached.container, cached.codec) + StreamInfo(cached.url, cached.protocol, cached.codec, cached.container) } } - suspend fun cacheStreamInfo(trackId: String, info: StreamInfo) { + private suspend fun cacheStreamInfo( + trackId: String, + info: StreamInfo, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + source: AudioSource.Streamed, + selectedStream: AudioStream, + ) { val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS streamUrlCacheMutex.withLock { - streamUrlCache[trackId] = - CachedStreamUrl( - url = info.url, - protocol = info.protocol, - expiresAtMs = expiresAtMs, - container = info.container, - codec = info.codec - ) + streamUrlCache[trackId] = CachedStreamUrl( + url = info.url, + protocol = info.protocol, + expiresAtMs = expiresAtMs, + container = info.container, + codec = info.codec, + source = source, + selectedStream = selectedStream, + preferredFormat = preferredFormat, + preferredQuality = preferredQuality, + ) } } @@ -232,6 +254,7 @@ class StreamingUrlRepository( logger.v { "Invalidated cached stream URL for track $trackId" } } } + // Stream metadata is part of the same URL-cache entry and is removed with it. } suspend fun getCachedAlternatives(trackId: String): List? { @@ -247,9 +270,77 @@ class StreamingUrlRepository( } } + /** Returns stream details from the existing playback URL cache; never fetches them. */ + suspend fun getCachedStreamUrlEntry(trackId: String): CachedStreamUrl? { + val now = Clock.System.now().toEpochMilliseconds() + return streamUrlCacheMutex.withLock { + val entry = streamUrlCache[trackId] ?: return@withLock null + if (entry.expiresAtMs <= now) { + logger.v { "Returning expired stream details for track $trackId" } + } + entry + } + } + suspend fun invalidateCachedAlternatives(trackId: String) { alternativesCacheMutex.withLock { alternativesCache.remove(trackId) } } -} \ No newline at end of file + +} + +/** + * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are + * resolved against the YouTube origin; progressive URLs are returned unchanged. + */ +internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { + if (url.startsWith("http")) return url + return when (protocol) { + StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com$url" + StreamProtocol.PROGRESSIVE -> url + } +} + +/** Selects the available stream closest to the user's preferred format and quality. */ +internal fun selectPreferredAudioStream( + streams: List, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, +): AudioStream? = streams.minWithOrNull( + compareBy { stream -> + formatMismatchCount(stream, preferredFormat) + }.thenBy { stream -> + if (stream.matchesQualityType(preferredQuality)) 0 else 1 + }.thenBy { stream -> + stream.qualityDistance(preferredQuality) + }.thenBy { stream -> + stream.channelDistance(preferredQuality) + } +) + +private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + + (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) + +private fun AudioStream.matchesQualityType(preferredQuality: AudioQuality): Boolean = when (this) { + is AudioStream.Lossy -> preferredQuality is AudioQuality.Lossy + is AudioStream.Lossless -> preferredQuality is AudioQuality.Lossless +} + +private fun AudioStream.qualityDistance(preferredQuality: AudioQuality): Long = when { + this is AudioStream.Lossy && preferredQuality is AudioQuality.Lossy -> + abs(bitrate.toLong() - preferredQuality.bitrate.toLong()) + + this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless -> + abs(sampleRate.toLong() - preferredQuality.sampleRate.toLong()) + + else -> Long.MAX_VALUE +} + +private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = when { + this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless -> + abs(channels - preferredQuality.channels) + + else -> 0 +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt index 1776e14f..4a897cf8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt @@ -146,7 +146,7 @@ internal fun availableAudioQualities(format: AudioFormat, current: AudioQuality) } internal fun AudioFormat.displayLabel(): String { - return "${codec.uppercase()} in ${container.uppercase()} • ${preferredQuality().displayLabel()}" + return "${codec.uppercase()} in ${container.uppercase()}" } internal fun AudioFormat.preferredQuality(): AudioQuality { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 96172f80..0016bc80 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -66,9 +66,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight @@ -77,7 +74,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState @@ -86,7 +82,6 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.BaseUITheme import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme @@ -155,6 +150,7 @@ fun AppExpandedPlayer( onSleepTimer: () -> Unit = {}, audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } @@ -169,6 +165,7 @@ fun AppExpandedPlayer( val downloadsViewModel: DownloadsViewModel = koinViewModel() val navigationCommands: NavigationCommands = koinInject() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() val currentTrack = remember(currentEntry) { (currentEntry as? QueueEntry.StreamingTrack)?.track } @@ -184,9 +181,10 @@ fun AppExpandedPlayer( val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - var showMoreOptionsSheet by remember { mutableStateOf(false) } val moreOptionsSheetState = rememberModalBottomSheetState() + PlayerOptionDialogs(viewModel = playerOptionsViewModel) + LaunchedEffect(playerUiState.progress, isSeeking) { if (!isSeeking) { @@ -241,10 +239,10 @@ fun AppExpandedPlayer( modifier = modifier.fillMaxSize(), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> - if (showMoreOptionsSheet) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { ModalBottomSheet( onDismissRequest = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() }, sheetState = moreOptionsSheetState ) { @@ -261,7 +259,7 @@ fun AppExpandedPlayer( icon = Iconsax.SwapHorizontal2, label = "Alternative Source", onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() onAlternativeSource() }, ) @@ -287,7 +285,7 @@ fun AppExpandedPlayer( modifier = Modifier .aspectRatio(1f) .clickable { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() when (status) { is DownloadStatus.Completed -> { scope.launch { @@ -375,7 +373,8 @@ fun AppExpandedPlayer( icon = Iconsax.InconsaxClock, label = "Sleep Timer", onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() + playerOptionsViewModel.showSleepTimerDialog() onSleepTimer() }, ) @@ -385,7 +384,7 @@ fun AppExpandedPlayer( icon = Iconsax.IconsaxCd, label = "Go to Album", onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() val albumId = currentTrack?.album?.id if (albumId != null) { navigationCommands.navigateTo(Routes.Album(albumId)) @@ -394,6 +393,16 @@ fun AppExpandedPlayer( }, ) } + item { + OptionTile( + icon = Iconsax.Iconsax3DotsMore, + label = "Track Details", + onClick = { + playerOptionsViewModel.dismissMoreOptionsSheet() + playerOptionsViewModel.showTrackDetails() + }, + ) + } } } } @@ -422,7 +431,7 @@ fun AppExpandedPlayer( textAlign = TextAlign.Center, maxLines = 1, ) - GhostIconButton(onClick = { showMoreOptionsSheet = true }) { + GhostIconButton(onClick = playerOptionsViewModel::showMoreOptionsSheet) { Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") } } @@ -744,4 +753,4 @@ private fun OptionTile( ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index a6e09477..80916351 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -18,7 +18,6 @@ package dev.krtirtho.spotube.modules.shell import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,6 +33,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -54,19 +55,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.hazeEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider @@ -122,6 +124,7 @@ fun AppLargePlayer( audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), downloadsViewModel: DownloadsViewModel = koinViewModel(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } @@ -134,11 +137,16 @@ fun AppLargePlayer( .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track + val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() + val navigationCommands: NavigationCommands = koinInject() var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + PlayerOptionDialogs(viewModel = playerOptionsViewModel) + LaunchedEffect(playerUiState.progress, isSeeking) { if (!isSeeking) { seekProgress = playerUiState.progress @@ -383,8 +391,45 @@ fun AppLargePlayer( GhostIconButton(onClick = onLyrics) { Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") } - GhostIconButton(onClick = onMoreOptions) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") + Box { + GhostIconButton( + onClick = { + playerOptionsViewModel.showMoreOptionsMenu() + onMoreOptions() + } + ) { + Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") + } + DropdownMenu( + expanded = playerOptionsUiState.isMoreOptionsMenuOpen, + onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu, + ) { + DropdownMenuItem( + text = { Text("Go to album") }, + enabled = currentTrack?.album?.id != null, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + currentTrack?.album?.id?.let { albumId -> + navigationCommands.navigateTo(Routes.Album(albumId)) + } + }, + ) + DropdownMenuItem( + text = { Text("Sleep timer") }, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showSleepTimerDialog() + }, + ) + DropdownMenuItem( + text = { Text("Track details") }, + enabled = currentTrack != null, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showTrackDetails() + }, + ) + } } } Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt new file mode 100644 index 00000000..706fb061 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt @@ -0,0 +1,401 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.shell + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.SecondaryButton +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive +import dev.krtirtho.spotube.core.server.CacheEntry + +@Composable +fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + if (uiState.isSleepTimerDialogOpen) { + SleepTimerDialog( + minutes = uiState.sleepTimerMinutes, + remainingMs = uiState.sleepTimerRemainingMs, + onMinutesChange = viewModel::updateSleepTimerMinutes, + onStart = viewModel::startSleepTimer, + onCancelTimer = viewModel::cancelSleepTimer, + onDismiss = viewModel::dismissSleepTimerDialog, + ) + } + + uiState.trackDetails?.let { details -> + TrackDetailsDialog( + details = details, + onDismiss = viewModel::dismissTrackDetails, + ) + } +} + +@Composable +private fun SleepTimerDialog( + minutes: String, + remainingMs: Long?, + onMinutesChange: (String) -> Unit, + onStart: () -> Unit, + onCancelTimer: () -> Unit, + onDismiss: () -> Unit, +) { + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text("Sleep timer", style = MaterialTheme.typography.titleLarge) + }, + actions = { + if (remainingMs != null) { + SecondaryButton(onClick = onCancelTimer) { Text("Cancel timer") } + } + SecondaryButton(onClick = onDismiss) { Text("Close") } + PrimaryButton( + onClick = onStart, + enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true, + ) { + Text("Start") + } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (remainingMs != null) { + Text( + text = "Playback will pause in ${formatRemainingTime(remainingMs)}.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + text = "Pause playback after the selected number of minutes.", + style = MaterialTheme.typography.bodyMedium, + ) + } + OutlinedTextField( + value = minutes, + onValueChange = onMinutesChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Minutes") }, + supportingText = { Text("Choose between 1 and 600 minutes") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + } + } +} + +@Composable +private fun TrackDetailsDialog( + details: PlayerTrackDetails, + onDismiss: () -> Unit, +) { + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Track details", style = MaterialTheme.typography.titleLarge) + Text( + details.track.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + SecondaryButton(onClick = onDismiss) { Text("Close") } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + if (details.isLoading) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } + + if (details.localCacheEntry != null) { + LocalCacheBanner(details.localCacheEntry) + } + if (details.localCacheEntry == null && details.activeStreamMatchesPreference == false) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + "The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request.", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + + TrackMetadataSection(details.track) + + Text( + "Matched audio source", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + val source = details.matchedSource + if (details.isLoading) { + Text("Loading matched source…", style = MaterialTheme.typography.bodySmall) + } else if (source == null) { + Text( + details.error ?: if (details.localCacheEntry != null) { + "A matched remote source is not needed while playing the local cached file." + } else { + "No matched source is available." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + DetailRow("Title", source.title) + DetailRow("ID", source.id) + DetailRow("Artist", source.artist) + DetailRow("Album", source.album) + DetailRow("Confidence", "${(source.confidence * 100).toInt()}%") + DetailRow("External URI", source.externalUri) + } + + Text( + "Available streams", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + when { + details.localCacheEntry != null && details.streams.isEmpty() -> Text( + "This track is being served from the local cache. Stream variants are not used for this playback.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + details.isLoading -> Text("Loading streams…", style = MaterialTheme.typography.bodySmall) + details.streams.isEmpty() -> Text( + details.error ?: "No streams are available for this source.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + else -> details.streams.forEach { stream -> + val isSelected = stream == details.preferredStream + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = streamFormatAndQuality(stream), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + imageVector = Iconsax.IconsaxCheckCircle, + contentDescription = "Currently selected stream", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + Text( + "${stream.protocol} • ${stream.codec} • ${stream.container}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stream.url, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (isSelected) { + Text( + "Matches your streaming format & quality preference", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + + details.activeStreamUrl?.let { activeUrl -> + DetailRow("Active playback URL", activeUrl) + } + } + } +} + +@Composable +private fun LocalCacheBanner(entry: CacheEntry) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Iconsax.IconsaxDirectboxReceive, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "Served from local cache", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + "${entry.filename} • ${formatFileSize(entry.sizeBytes)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +@Composable +private fun TrackMetadataSection(track: MetadataTrack) { + Text( + "Metadata track", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + DetailRow("ID", track.id) + DetailRow("Title", track.title) + DetailRow("Artists", track.artists.joinToString { it.name }) + DetailRow("Album", track.album?.title) + DetailRow("Album ID", track.album?.id) + DetailRow("Album type", track.album?.albumType?.name) + DetailRow("Album release date", track.album?.releaseDate) + DetailRow("Album track count", track.album?.trackCount?.toString()) + DetailRow("Album genres", track.album?.genres?.joinToString()) + DetailRow("Duration", formatRemainingTime(track.durationMs)) + DetailRow("Track number", track.trackNumber?.toString()) + DetailRow("Disc number", track.discNumber?.toString()) + DetailRow("Explicit", track.explicit?.toString()) + DetailRow("Popularity", track.popularity?.toString()) + DetailRow("ISRC", track.isrcCode) + DetailRow("External URI", track.externalUri) + DetailRow("Thumbnail URLs", track.thumbnails?.joinToString { it.url }) +} + +@Composable +private fun DetailRow(label: String, value: String?) { + if (value.isNullOrBlank()) return + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + label, + modifier = Modifier.weight(0.38f), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + value, + modifier = Modifier.weight(0.62f), + style = MaterialTheme.typography.bodySmall, + ) + } +} + +private fun streamFormatAndQuality(stream: AudioStream): String = when (stream) { + is AudioStream.Lossy -> "${stream.bitrate / 1_000} kbps" + is AudioStream.Lossless -> { + val rate = if (stream.sampleRate % 1_000 == 0) { + "${stream.sampleRate / 1_000}" + } else { + "${stream.sampleRate / 1_000.0}" + } + "$rate kHz • ${stream.channels} ch" + } +} + +private fun formatRemainingTime(milliseconds: Long): String { + val totalSeconds = (milliseconds / 1_000).coerceAtLeast(0) + val hours = totalSeconds / 3_600 + val minutes = (totalSeconds % 3_600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) { + "$hours:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" + } else { + "$minutes:${seconds.toString().padStart(2, '0')}" + } +} + +private fun formatFileSize(sizeBytes: Long): String = when { + sizeBytes >= 1024L * 1024L -> "${sizeBytes / (1024L * 1024L)} MB" + sizeBytes >= 1024L -> "${sizeBytes / 1024L} KB" + else -> "$sizeBytes B" +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt new file mode 100644 index 00000000..8a22546e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -0,0 +1,241 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.shell + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.server.CacheEntry +import dev.krtirtho.spotube.core.server.CacheManager +import dev.krtirtho.spotube.core.server.MatchedTracksRepository +import dev.krtirtho.spotube.core.server.StreamingUrlRepository +import dev.krtirtho.spotube.core.server.normalizeManifestUrl +import dev.krtirtho.spotube.core.server.selectPreferredAudioStream +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +data class PlayerTrackDetails( + val track: MetadataTrack, + val matchedSource: AudioSource.Basic? = null, + val streams: List = emptyList(), + /** Stream picked by the current streaming format/quality preference. */ + val preferredStream: AudioStream? = null, + /** URL currently used for playback, read from the playback URL cache. */ + val activeStreamUrl: String? = null, + /** Whether the active URL matches what the current preferences select. */ + val activeStreamMatchesPreference: Boolean? = null, + val localCacheEntry: CacheEntry? = null, + val isLoading: Boolean = false, + val error: String? = null, +) + +data class PlayerOptionsUiState( + val isMoreOptionsMenuOpen: Boolean = false, + val isMoreOptionsSheetOpen: Boolean = false, + val isSleepTimerDialogOpen: Boolean = false, + val sleepTimerMinutes: String = "30", + val sleepTimerRemainingMs: Long? = null, + val trackDetails: PlayerTrackDetails? = null, +) + +class PlayerOptionsViewModel( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val matchedTracksRepository: MatchedTracksRepository, + private val streamingUrlRepository: StreamingUrlRepository, + private val cacheManager: CacheManager, + private val settingsRepository: SettingsRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(PlayerOptionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var sleepTimerJob: Job? = null + private var trackDetailsJob: Job? = null + + fun showMoreOptionsMenu() { + _uiState.update { + it.copy(isMoreOptionsMenuOpen = true, isMoreOptionsSheetOpen = false) + } + } + + fun dismissMoreOptionsMenu() { + _uiState.update { it.copy(isMoreOptionsMenuOpen = false) } + } + + fun showMoreOptionsSheet() { + _uiState.update { + it.copy(isMoreOptionsSheetOpen = true, isMoreOptionsMenuOpen = false) + } + } + + fun dismissMoreOptionsSheet() { + _uiState.update { it.copy(isMoreOptionsSheetOpen = false) } + } + + fun showSleepTimerDialog() { + _uiState.update { it.copy(isSleepTimerDialogOpen = true) } + } + + fun dismissSleepTimerDialog() { + _uiState.update { it.copy(isSleepTimerDialogOpen = false) } + } + + fun updateSleepTimerMinutes(minutes: String) { + if (minutes.all(Char::isDigit)) { + _uiState.update { it.copy(sleepTimerMinutes = minutes) } + } + } + + fun startSleepTimer() { + val minutes = _uiState.value.sleepTimerMinutes.toLongOrNull() + ?.takeIf { it in 1..600 } ?: return + val durationMs = minutes * MILLIS_PER_MINUTE + + sleepTimerJob?.cancel() + sleepTimerJob = viewModelScope.launch { + var remainingMs = durationMs + while (remainingMs > 0L) { + _uiState.update { it.copy(sleepTimerRemainingMs = remainingMs) } + val tickMs = minOf(remainingMs, MILLIS_PER_SECOND) + delay(tickMs.milliseconds) + remainingMs -= tickMs + } + runCatching { audioPlayer.pause() } + _uiState.update { it.copy(sleepTimerRemainingMs = null, isSleepTimerDialogOpen = false) } + } + } + + fun cancelSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + _uiState.update { it.copy(sleepTimerRemainingMs = null) } + } + + fun showTrackDetails() { + val track = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack) + ?.track ?: return + trackDetailsJob?.cancel() + _uiState.update { + it.copy( + trackDetails = PlayerTrackDetails(track = track, isLoading = true), + ) + } + trackDetailsJob = viewModelScope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + withContext(Dispatchers.IO) { + val matchedSource = matchedTracksRepository.getTrackSource(track) + val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id) + val localCacheEntry = if (settings.enableMusicCaching) { + cacheManager.findCachedEntry(track.id)?.second + } else { + null + } + val cachedStreamSource = cachedStream?.source?.takeIf { + matchedSource == null || it.id == matchedSource.id + } + + val source = matchedSource ?: cachedStreamSource?.toBasic() + val streams = cachedStreamSource?.streams.orEmpty() + val preferredStream = selectPreferredAudioStream( + streams = streams, + preferredFormat = settings.streamingMusicFormat, + preferredQuality = settings.streamingMusicQuality, + ) + val activeStreamUrl = if (cachedStreamSource != null) cachedStream.url else null + val activeStreamMatchesPreference = when { + activeStreamUrl == null || preferredStream == null -> null + else -> activeStreamUrl == normalizeManifestUrl( + url = preferredStream.url, + protocol = preferredStream.protocol, + ) + } + + PlayerTrackDetails( + track = track, + matchedSource = source, + streams = streams, + preferredStream = preferredStream, + activeStreamUrl = activeStreamUrl, + activeStreamMatchesPreference = activeStreamMatchesPreference, + localCacheEntry = localCacheEntry, + error = when { + source == null && localCacheEntry == null -> + "No matched audio source is cached for this track." + streams.isEmpty() && localCacheEntry == null -> + "Stream details are not cached yet. Play the track first to load them." + else -> null + }, + ) + } + }.onSuccess { details -> + _uiState.update { current -> + if (current.trackDetails?.track?.id == track.id) { + current.copy(trackDetails = details) + } else current + } + }.onFailure { error -> + if (error is CancellationException) throw error + _uiState.update { current -> + if (current.trackDetails?.track?.id == track.id) { + current.copy( + trackDetails = PlayerTrackDetails( + track = track, + isLoading = false, + error = error.message ?: "Failed to load track details.", + ), + ) + } else current + } + } + } + } + + fun dismissTrackDetails() { + trackDetailsJob?.cancel() + trackDetailsJob = null + _uiState.update { it.copy(trackDetails = null) } + } + + override fun onCleared() { + sleepTimerJob?.cancel() + trackDetailsJob?.cancel() + super.onCleared() + } + + companion object { + private const val MILLIS_PER_SECOND = 1_000L + private const val MILLIS_PER_MINUTE = 60_000L + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt new file mode 100644 index 00000000..97f72cc4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -0,0 +1,434 @@ +package dev.krtirtho.spotube.core.server + +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * Tests for the stream preference selection used by [StreamingUrlRepository]. + * + * Ordering contract (best first): + * 1. Fewest codec/container mismatches against the preferred format. + * 2. Matching quality family (lossy vs lossless). + * 3. Closest bitrate / sample rate. + * 4. Closest channel count (lossless only). + * 5. First occurrence wins remaining ties. + */ +class StreamingUrlRepositoryTest { + + // ---------- helpers ---------- + + private fun opusWebm() = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + private fun flac() = AudioFormat(codec = "flac", container = "flac", qualities = emptyList()) + + private fun lossy( + url: String, + bitrate: Int, + codec: String = "opus", + container: String = "webm", + protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + ) = AudioStream.Lossy( + url = url, + codec = codec, + container = container, + protocol = protocol, + bitrate = bitrate, + ) + + private fun lossless( + url: String, + sampleRate: Int, + channels: Int, + codec: String = "flac", + container: String = "flac", + ) = AudioStream.Lossless( + url = url, + codec = codec, + container = container, + sampleRate = sampleRate, + channels = channels, + ) + + private fun select( + streams: List, + format: AudioFormat = opusWebm(), + quality: AudioQuality = AudioQuality.Lossy(256_000), + ) = selectPreferredAudioStream(streams, format, quality) + + // ---------- empty / single ---------- + + @Test + fun `returns null when no streams are available`() { + assertNull(select(emptyList())) + } + + @Test + fun `returns the only stream when exactly one is available`() { + val only = lossy("opus-128", 128_000) + assertSame(only, select(listOf(only))) + } + + @Test + fun `does not mutate the input list`() { + val streams = mutableListOf( + lossy("opus-44", 44_000), + lossy("opus-256", 256_000), + ) + val snapshot = streams.toList() + + select(streams) + + assertEquals(snapshot, streams) + } + + // ---------- format priority ---------- + + @Test + fun `prefers an exact codec and container match over a closer quality in another format`() { + val otherFormat = lossy("aac-256", 256_000, codec = "aac", container = "mp4") + val preferredFormat = lossy("opus-128", 128_000) + + val result = select( + streams = listOf(otherFormat, preferredFormat), + quality = AudioQuality.Lossy(256_000), + ) + + assertSame(preferredFormat, result) + } + + @Test + fun `format matching ignores codec and container case`() { + val exact = lossy("opus-128", 128_000, codec = "OpUs", container = "WeBm") + val mismatch = lossy("aac-128", 128_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(mismatch, exact), + format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList()), + ) + + assertSame(exact, result) + } + + @Test + fun `a partial format match beats a full format mismatch regardless of bitrate`() { + val partial = lossy("opus-mp4-320", 320_000, container = "mp4") + val fullMismatch = lossy("aac-mp4-44", 44_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(fullMismatch, partial), + quality = AudioQuality.Lossy(44_000), + ) + + assertSame(partial, result) + } + + @Test + fun `a one-field match beats a two-field mismatch`() { + val oneField = lossy("opus-ogg", 128_000, container = "ogg") + val twoFields = lossy("aac-mp4", 128_000, codec = "aac", container = "mp4") + + val result = select(streams = listOf(twoFields, oneField)) + + assertSame(oneField, result) + } + + @Test + fun `codec-only and container-only matches tie on format and are resolved by quality`() { + val codecOnly = lossy("opus-mp4-320", 320_000, container = "mp4") + val containerOnly = lossy("aac-webm-44", 44_000, codec = "aac", container = "webm") + + val result = select( + streams = listOf(codecOnly, containerOnly), + quality = AudioQuality.Lossy(44_000), + ) + + assertSame(containerOnly, result) + } + + @Test + fun `when nothing matches the format the closest quality is chosen`() { + val low = lossy("aac-128", 128_000, codec = "aac", container = "mp4") + val target = lossy("aac-256", 256_000, codec = "aac", container = "mp4") + val high = lossy("aac-320", 320_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(low, target, high), + quality = AudioQuality.Lossy(256_000), + ) + + assertSame(target, result) + } + + @Test + fun `format match is prioritized over matching the quality family`() { + // Same format but wrong family vs. wrong format but correct family. + val sameFormatWrongFamily = lossless("opus-webm-flac", 48_000, 2, codec = "opus", container = "webm") + val wrongFormatRightFamily = lossy("aac-44", 44_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(wrongFormatRightFamily, sameFormatWrongFamily), + quality = AudioQuality.Lossy(44_000), + ) + + assertSame(sameFormatWrongFamily, result) + } + + // ---------- lossy quality ---------- + + @Test + fun `exact lossy bitrate wins`() { + val low = lossy("opus-44", 44_000) + val exact = lossy("opus-128", 128_000) + val high = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(low, exact, high), + quality = AudioQuality.Lossy(128_000), + ) + + assertSame(exact, result) + } + + @Test + fun `closest lower lossy bitrate wins`() { + val lower = lossy("opus-128", 128_000) + val higher = lossy("opus-320", 320_000) + + val result = select( + streams = listOf(higher, lower), + quality = AudioQuality.Lossy(200_000), + ) + + assertSame(lower, result) + } + + @Test + fun `closest higher lossy bitrate wins`() { + val lower = lossy("opus-44", 44_000) + val higher = lossy("opus-128", 128_000) + + val result = select( + streams = listOf(lower, higher), + quality = AudioQuality.Lossy(100_000), + ) + + assertSame(higher, result) + } + + @Test + fun `equidistant lossy bitrates pick the first occurrence`() { + val below = lossy("opus-96", 96_000) + val above = lossy("opus-104", 104_000) + + assertSame(below, select(listOf(below, above), quality = AudioQuality.Lossy(100_000))) + assertSame(above, select(listOf(above, below), quality = AudioQuality.Lossy(100_000))) + } + + @Test + fun `lossy preference prefers a lossy stream over lossless in the same format`() { + val losslessStream = lossless("opus-webm-lossless", 48_000, 2, codec = "opus", container = "webm") + val lossyStream = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(losslessStream, lossyStream), + quality = AudioQuality.Lossy(128_000), + ) + + assertSame(lossyStream, result) + } + + @Test + fun `lossy distance does not overflow for extreme bitrates`() { + val min = lossy("opus-min", Int.MIN_VALUE) + val max = lossy("opus-max", Int.MAX_VALUE) + + val result = select( + streams = listOf(min, max), + quality = AudioQuality.Lossy(Int.MAX_VALUE), + ) + + assertSame(max, result) + } + + // ---------- lossless quality ---------- + + @Test + fun `exact lossless sample rate and channels wins`() { + val otherRate = lossless("flac-44", 44_100, 2) + val mono = lossless("flac-48-mono", 48_000, 1) + val exact = lossless("flac-48-stereo", 48_000, 2) + + val result = select( + streams = listOf(otherRate, mono, exact), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), + ) + + assertSame(exact, result) + } + + @Test + fun `closest lossless sample rate wins`() { + val near = lossless("flac-44", 44_100, 2) + val far = lossless("flac-96", 96_000, 2) + + val result = select( + streams = listOf(far, near), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), + ) + + assertSame(near, result) + } + + @Test + fun `lossless sample rate tie is broken by channel count`() { + val mono = lossless("flac-44-mono", 44_100, 1) + val stereo = lossless("flac-44-stereo", 44_100, 2) + + val result = select( + streams = listOf(mono, stereo), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), + ) + + assertSame(stereo, result) + } + + @Test + fun `lossless preference prefers a lossless stream over lossy in the same format`() { + val lossyStream = lossy("flac-flac-lossy", 128_000, codec = "flac", container = "flac") + val losslessStream = lossless("flac-44", 44_100, 2) + + val result = select( + streams = listOf(lossyStream, losslessStream), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), + ) + + assertSame(losslessStream, result) + } + + // ---------- quality-family fallbacks ---------- + + @Test + fun `lossy preference with only lossless streams still returns the first stream`() { + val first = lossless("flac-44", 44_100, 2) + val second = lossless("flac-48", 48_000, 2) + + val result = select( + streams = listOf(first, second), + format = flac(), + quality = AudioQuality.Lossy(256_000), + ) + + assertSame(first, result) + } + + @Test + fun `lossless preference with only lossy streams still returns the first stream`() { + val first = lossy("opus-128", 128_000) + val second = lossy("opus-320", 320_000) + + val result = select( + streams = listOf(first, second), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), + ) + + assertSame(first, result) + } + + // ---------- degenerate / orthogonal inputs ---------- + + @Test + fun `empty preferred codec and container fall back to quality`() { + val low = lossy("opus-44", 44_000) + val target = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(low, target), + format = AudioFormat(codec = "", container = "", qualities = emptyList()), + quality = AudioQuality.Lossy(256_000), + ) + + assertSame(target, result) + } + + @Test + fun `protocol does not influence selection`() { + val progressive = lossy("a", 128_000, protocol = StreamProtocol.PROGRESSIVE) + val dash = lossy("b", 128_000, protocol = StreamProtocol.DASH) + val hls = lossy("c", 128_000, protocol = StreamProtocol.HLS) + + val result = select(streams = listOf(progressive, dash, hls)) + + assertSame(progressive, result) + } + + @Test + fun `selection is stable across repeated calls`() { + val streams = listOf( + lossy("opus-44", 44_000), + lossy("opus-128", 128_000), + lossy("opus-256", 256_000), + ) + + val first = select(streams, quality = AudioQuality.Lossy(100_000)) + val second = select(streams, quality = AudioQuality.Lossy(100_000)) + + assertSame(first, second) + } + + // ---------- normalizeManifestUrl ---------- + + @Test + fun `absolute http urls are returned unchanged`() { + val url = "http://example.com/audio.mp3" + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `absolute https urls are returned unchanged for every protocol`() { + val url = "https://example.com/manifest.m3u8" + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.HLS)) + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.DASH)) + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `relative hls manifest paths are resolved against the youtube origin`() { + assertEquals( + "https://www.youtube.com/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS), + ) + } + + @Test + fun `relative dash manifest paths are resolved against the youtube origin`() { + assertEquals( + "https://www.youtube.com/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH), + ) + } + + @Test + fun `relative progressive urls are returned unchanged`() { + assertEquals( + "/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE), + ) + } + + @Test + fun `empty url is handled without throwing`() { + assertEquals("", normalizeManifestUrl("", StreamProtocol.PROGRESSIVE)) + assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) + assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) + } +} diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt index 28f0eeb4..4f2d341e 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt @@ -28,6 +28,7 @@ data class AudioFormat( val qualities: List ) +@Serializable sealed interface AudioQuality { @Serializable @SerialName("lossy") diff --git a/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt b/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt new file mode 100644 index 00000000..b676d48f --- /dev/null +++ b/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt @@ -0,0 +1,28 @@ +package dev.krtirtho.plugin_interfaces.plugin_apis.audio + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class AudioQualitySerializationTest { + @Test + fun `audio format serializes sealed quality variants`() { + val formats = listOf( + AudioFormat( + codec = "opus", + container = "webm", + qualities = listOf(AudioQuality.Lossy(bitrate = 256_000)), + ), + AudioFormat( + codec = "flac", + container = "flac", + qualities = listOf(AudioQuality.Lossless(sampleRate = 48_000, channels = 2)), + ), + ) + + val encoded = Json.encodeToString(formats) + val decoded = Json.decodeFromString>(encoded) + + assertEquals(formats, decoded) + } +} From df3dd2ad88259e2a16cb30e2ab726d93c83bdce1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 15:03:24 +0600 Subject: [PATCH 019/132] feat(player-options): enhance options menu with localized strings and icons --- .../composeResources/values/strings.xml | 4 +++ .../kotlin/dev/krtirtho/spotube/Greeting.kt | 26 --------------- .../spotube/modules/shell/AppLargePlayer.kt | 32 +++++++++++++++++-- 3 files changed, 33 insertions(+), 29 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Greeting.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 9341f547..e9b01eb9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -187,5 +187,9 @@ Connected in %1$d ms Failed: %1$s Placeholder broker — configure your own to self-host + + Go to album + Sleep timer + Track details diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Greeting.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Greeting.kt deleted file mode 100644 index 5d53f202..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Greeting.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 80916351..f9032b9a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -82,7 +82,9 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive +import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter import dev.krtirtho.spotube.resources.iconsax.IconsaxNext @@ -96,12 +98,18 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow +import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_go_to_album +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_track_details import kotlin.time.Duration.Companion.milliseconds @@ -405,7 +413,13 @@ fun AppLargePlayer( onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu, ) { DropdownMenuItem( - text = { Text("Go to album") }, + text = { Text(stringResource(Res.string.player_go_to_album)) }, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + ) + }, enabled = currentTrack?.album?.id != null, onClick = { playerOptionsViewModel.dismissMoreOptionsMenu() @@ -415,14 +429,26 @@ fun AppLargePlayer( }, ) DropdownMenuItem( - text = { Text("Sleep timer") }, + text = { Text(stringResource(Res.string.player_sleep_timer)) }, + leadingIcon = { + Icon( + imageVector = Iconsax.InconsaxClock, + contentDescription = null, + ) + }, onClick = { playerOptionsViewModel.dismissMoreOptionsMenu() playerOptionsViewModel.showSleepTimerDialog() }, ) DropdownMenuItem( - text = { Text("Track details") }, + text = { Text(stringResource(Res.string.player_track_details)) }, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxInformation, + contentDescription = null, + ) + }, enabled = currentTrack != null, onClick = { playerOptionsViewModel.dismissMoreOptionsMenu() From a4d157e9c2eea42d2787b3fc7878bcae6ae98080 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 16:44:48 +0600 Subject: [PATCH 020/132] feat(jam-settings): implement JamSettings screen and view model for broker configuration --- .../composeResources/values/strings.xml | 8 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../core/navigation/NavigationModule.kt | 8 +- .../modules/settings/JamSettingsScreen.kt | 274 ++++++++++++++++++ .../modules/settings/JamSettingsViewModel.kt | 51 ++++ .../modules/settings/SettingsScreen.kt | 3 +- .../modules/settings/sections/JamSection.kt | 188 ++---------- 7 files changed, 363 insertions(+), 171 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e9b01eb9..ec223223 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -182,14 +182,20 @@ Username (optional) Password (optional) Client ID prefix + Used to identify this Spotube instance + Encrypt the broker connection + Check the current broker settings before starting a room. Test connection Testing… Connected in %1$d ms Failed: %1$s Placeholder broker — configure your own to self-host + Connection + Credentials + Identity + Diagnostics Go to album Sleep timer Track details - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 9c9c7964..511332ba 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -74,6 +74,7 @@ import dev.krtirtho.spotube.modules.search.SearchScreenViewModel import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -127,6 +128,7 @@ val sharedModules = module { // Settings singleOf(::SettingsRepository) viewModelOf(::SettingsViewModel) { bind() } + viewModelOf(::JamSettingsViewModel) // Downloads singleOf(::DownloadManager) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index bfea9743..b3f7f48f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -33,6 +33,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen +import dev.krtirtho.spotube.modules.settings.JamSettingsScreen import kotlinx.serialization.Serializable import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI @@ -87,6 +88,9 @@ sealed interface Routes : NavKey { @Serializable data object Jam : Routes + + @Serializable + data object JamSettings : Routes } @OptIn(KoinExperimentalAPI::class) @@ -171,5 +175,7 @@ val navigationModule = module { navigation { JamScreen(navigationCommands = get()) } + navigation { + JamSettingsScreen(viewModel = koinViewModel()) + } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt new file mode 100644 index 00000000..3264af61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt @@ -0,0 +1,274 @@ +package dev.krtirtho.spotube.modules.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard +import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard +import dev.krtirtho.spotube.modules.settings.sections.SettingsItemIcon +import dev.krtirtho.spotube.modules.settings.sections.settingsSectionCard +import dev.krtirtho.spotube.modules.settings.sections.settingsSectionHeader +import dev.krtirtho.spotube.resources.iconsax.CustomServer +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxEye +import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation +import dev.krtirtho.spotube.resources.iconsax.IconsaxTag +import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare +import dev.krtirtho.spotube.resources.iconsax.User +import dev.krtirtho.spotube.modules.settings.components.SettingCardItem +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_error_port_range +import spotube.composeapp.generated.resources.settings_error_whole_number +import spotube.composeapp.generated.resources.settings_jam_broker_client_id +import spotube.composeapp.generated.resources.settings_jam_broker_client_id_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_diagnostics_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_host +import spotube.composeapp.generated.resources.settings_jam_broker_password +import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note +import spotube.composeapp.generated.resources.settings_jam_broker_port +import spotube.composeapp.generated.resources.settings_jam_broker_test +import spotube.composeapp.generated.resources.settings_jam_broker_testing +import spotube.composeapp.generated.resources.settings_jam_broker_tls +import spotube.composeapp.generated.resources.settings_jam_broker_tls_subtitle +import spotube.composeapp.generated.resources.settings_jam_section_connection +import spotube.composeapp.generated.resources.settings_jam_section_credentials +import spotube.composeapp.generated.resources.settings_jam_section_diagnostics +import spotube.composeapp.generated.resources.settings_jam_section_identity +import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_section_jam + +@Composable +fun JamSettingsScreen( + viewModel: JamSettingsViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val broker = uiState.settings.jamBroker + val shellBottomInset = LocalAppShellBottomInset.current + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + Scaffold( + topBar = { + ApplicationMainBar( + title = { Text(stringResource(Res.string.settings_section_jam)) }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding, + ) { + settingsSectionHeader(Res.string.settings_jam_section_connection) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_host), + value = broker.host, + placeholder = "broker.example.com", + icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") }, + onValueSaved = { host -> + viewModel.update { + copy( + jamBroker = jamBroker.copy( + host = host + ) + ) + } + }, + ) + }, + { + val errorWholeNumber = + stringResource(Res.string.settings_error_whole_number) + val errorPortRange = + stringResource(Res.string.settings_error_port_range) + + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_port), + value = broker.port.toString(), + placeholder = "1883", + normalize = { it.filter(Char::isDigit).take(5) }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> errorWholeNumber + port !in 1..65535 -> errorPortRange + else -> null + } + }, + onValueSaved = { port -> + viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) } + }, + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_port) + ) + }, + ) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_jam_broker_tls), + subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle), + checked = broker.useTls, + onCheckedChange = { tls -> + viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxWifiSquare, + stringResource(Res.string.settings_jam_broker_tls) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_credentials) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_username), + value = broker.username.orEmpty(), + placeholder = "anonymous", + onValueSaved = { username -> + viewModel.update { + copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.User, + stringResource(Res.string.settings_jam_broker_username) + ) + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_password), + value = broker.password.orEmpty(), + placeholder = "••••••••", + onValueSaved = { password -> + viewModel.update { + copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxEye, + stringResource(Res.string.settings_jam_broker_password) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_identity) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_client_id), + subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle), + value = broker.clientIdPrefix, + placeholder = "spotube", + onValueSaved = { prefix -> + viewModel.update { + copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxTag, + stringResource(Res.string.settings_jam_broker_client_id) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_diagnostics) + settingsSectionCard( + items = listOf( + { + val canTest = broker.host.isNotBlank() && !uiState.isTesting + val resultMessage = uiState.testResult?.fold( + onSuccess = { it }, + onFailure = { "Failed: ${it.message ?: "Connection failed"}" }, + ) + + SettingCardItem( + enabled = canTest, + title = stringResource(Res.string.settings_jam_broker_test), + subtitle = when { + uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing) + resultMessage != null -> resultMessage + else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle) + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxInformation, + stringResource(Res.string.settings_jam_broker_test), + ) + }, + trailingContent = { + PrimaryButton( + onClick = viewModel::testConnection, + enabled = canTest, + ) { + Text( + if (uiState.isTesting) { + stringResource(Res.string.settings_jam_broker_testing) + } else { + stringResource(Res.string.settings_jam_broker_test) + }, + ) + } + }, + onClick = viewModel::testConnection, + ) + }, + ), + ) + item { + Text( + text = stringResource(Res.string.settings_jam_broker_placeholder_note), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt new file mode 100644 index 00000000..010fc6e2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt @@ -0,0 +1,51 @@ +package dev.krtirtho.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.jam.JamRoomClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class JamSettingsUiState( + val settings: UserSettings = UserSettings(), + val isTesting: Boolean = false, + val testResult: Result? = null, +) + +class JamSettingsViewModel( + private val settingsRepository: SettingsRepository, + private val jamRoomClient: JamRoomClient, +) : ViewModel() { + private val _uiState = MutableStateFlow(JamSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + settingsRepository.userSettings.collect { settings -> + _uiState.update { it.copy(settings = settings) } + } + } + } + + fun update(transform: UserSettings.() -> UserSettings) { + viewModelScope.launch { + settingsRepository.updateSettings(uiState.value.settings.transform()) + } + } + + fun testConnection() { + if (_uiState.value.isTesting) return + _uiState.update { it.copy(isTesting = true, testResult = null) } + viewModelScope.launch { + val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) + _uiState.update { it.copy(isTesting = false, testResult = result) } + } + } + + fun clearTestResult() { + _uiState.update { it.copy(testResult = null) } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 2321ecef..f7119895 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -114,7 +114,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { if (settingsState != null) jamSection( settings = settingsState!!, - settingsViewModel = settingsViewModel, + navigationCommands = navigatorCommands, ) if (settingsState != null) cacheSection( @@ -142,4 +142,3 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { } } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt index a15326da..9feaedf3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -5,204 +5,58 @@ * 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 . */ package dev.krtirtho.spotube.modules.settings.sections -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.jam.JamRoomClient -import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard +import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.Iconsax -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.settings_jam_broker_client_id -import spotube.composeapp.generated.resources.settings_jam_broker_host import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle -import spotube.composeapp.generated.resources.settings_jam_broker_password -import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note -import spotube.composeapp.generated.resources.settings_jam_broker_port -import spotube.composeapp.generated.resources.settings_jam_broker_test -import spotube.composeapp.generated.resources.settings_jam_broker_test_fail -import spotube.composeapp.generated.resources.settings_jam_broker_test_ok -import spotube.composeapp.generated.resources.settings_jam_broker_testing -import spotube.composeapp.generated.resources.settings_jam_broker_tls -import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_jam_broker_title import spotube.composeapp.generated.resources.settings_section_jam internal fun LazyListScope.jamSection( settings: UserSettings, - settingsViewModel: SettingsViewModel, + navigationCommands: NavigationCommands, ) { - val broker = settings.jamBroker - settingsSectionHeader(Res.string.settings_section_jam) settingsSectionCard( items = listOf( { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_host), + SettingCardItem( + title = stringResource(Res.string.settings_jam_broker_title), subtitle = stringResource( Res.string.settings_jam_broker_host_subtitle, - broker.host.ifBlank { "—" }, - broker.port, + settings.jamBroker.host.ifBlank { "—" }, + settings.jamBroker.port, ), - value = broker.host, - onValueSaved = { host -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(host = host)) - } - }, - placeholder = "broker.example.com", icon = { SettingsItemIcon( Iconsax.CustomServer, - stringResource(Res.string.settings_jam_broker_host), + stringResource(Res.string.settings_jam_broker_title), ) }, - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_port), - value = broker.port.toString(), - onValueSaved = { port -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883)) - } - }, - placeholder = "1883", - normalize = { it.filter { c -> c.isDigit() }.take(5) }, - validate = { input -> - val port = input.toIntOrNull() - if (port == null || port !in 1..65535) "Invalid port" else null - }, - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_jam_broker_tls), - checked = broker.useTls, - onCheckedChange = { tls -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(useTls = tls)) - } - }, - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_username), - value = broker.username.orEmpty(), - onValueSaved = { username -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) - } - }, - placeholder = "anonymous", - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_password), - value = broker.password.orEmpty(), - onValueSaved = { password -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) - } - }, - placeholder = "••••••••", - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_client_id), - value = broker.clientIdPrefix, - onValueSaved = { prefix -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) - } - }, - placeholder = "spotube", - ) - }, - { - val jamClient = koinInject() - val scope = rememberCoroutineScope() - var testing by remember { mutableStateOf(false) } - var result by remember { mutableStateOf(null) } - - Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) { - Button( - onClick = { - testing = true - result = null - scope.launch { - val outcome = jamClient.testConnection(broker) - result = outcome.fold( - onSuccess = { ok -> "OK: $ok" }, - onFailure = { e -> "ERR: ${e.message ?: "unknown"}" }, - ) - testing = false - } - }, - enabled = broker.host.isNotBlank() && !testing, - ) { - Text( - text = if (testing) { - stringResource(Res.string.settings_jam_broker_testing) - } else { - stringResource(Res.string.settings_jam_broker_test) - }, + trailingContent = { + Icon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_jam_broker_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - result?.let { message -> - val ok = message.startsWith("OK:") - Text( - text = message.removePrefix("OK:").removePrefix("ERR:"), - style = MaterialTheme.typography.bodySmall, - color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, - modifier = Modifier.padding(start = 12.dp), - ) - } - } - }, - { - Text( - text = stringResource(Res.string.settings_jam_broker_placeholder_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), + }, + onClick = { navigationCommands.navigateTo(Routes.JamSettings) }, ) }, ) ) -} \ No newline at end of file +} From 50c3b04eb16bb259d388503bace334158874f533 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 18:29:08 +0600 Subject: [PATCH 021/132] feat: refactor track source management to support plugin-specific caching and retrieval --- .../dev/krtirtho/spotube/core/di/Modules.kt | 21 +- .../server/AlternativeTracksRepository.kt | 27 +- .../core/server/MatchTracksRepository.kt | 46 +- .../core/server/StreamingUrlRepository.kt | 74 ++- .../core/zipline/BuiltInPluginService.kt | 1 + .../spotube/core/zipline/PluginService.kt | 1 + .../core/zipline/ZiplinePluginService.kt | 2 + .../spotube/modules/plugin/PluginManager.kt | 11 +- .../modules/settings/SettingsRepository.kt | 11 +- .../modules/shell/PlayerOptionsViewModel.kt | 9 +- .../core/audioplayer/FakePluginProvider.kt | 3 +- .../StreamingUrlRepositoryBehaviorTest.kt | 556 ++++++++++++++++++ .../server/StreamingUrlRepositoryFakes.kt | 137 +++++ 13 files changed, 842 insertions(+), 57 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 511332ba..4853e271 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -37,6 +37,7 @@ import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager import dev.krtirtho.spotube.core.server.LocalServer import dev.krtirtho.spotube.core.server.MatchedTracksRepository +import dev.krtirtho.spotube.core.server.TrackSourceRepository import dev.krtirtho.spotube.core.server.StreamingUrlRepository import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.modules.album.AlbumRepository @@ -64,6 +65,7 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel +import dev.krtirtho.spotube.modules.plugin.AudioPluginSource import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginViewModel @@ -73,6 +75,7 @@ import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.modules.search.SearchScreenViewModel import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository +import dev.krtirtho.spotube.modules.settings.UserSettingsSource import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel @@ -122,11 +125,14 @@ val sharedModules = module { viewModelOf(::LibraryLocalTracksViewModel) // Plugin system - singleOf(::PluginManager) { bind() } + singleOf(::PluginManager) { + bind() + bind() + } viewModelOf(::PluginViewModel) // Settings - singleOf(::SettingsRepository) + singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) @@ -226,8 +232,15 @@ val sharedModules = module { blacklistRepository = get(), ) } - singleOf(::MatchedTracksRepository) - singleOf(::StreamingUrlRepository) + singleOf(::MatchedTracksRepository) { bind() } + single { + StreamingUrlRepository( + pluginManager = get(), + audioPlayerQueue = get(), + matchedTracksRepository = get(), + settingsRepository = get(), + ) + } singleOf(::CacheManager) singleOf(::AlternativeTracksRepository) singleOf(::LocalServer) withOptions { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt index 42e431af..77ad48f0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt @@ -21,12 +21,12 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.PluginManager +import dev.krtirtho.spotube.modules.plugin.AudioPluginSource import org.koin.core.component.KoinComponent class AlternativeTracksRepository( - private val pluginManager: PluginManager, - private val matchedTracksRepository: MatchedTracksRepository, + private val pluginManager: AudioPluginSource, + private val matchedTracksRepository: TrackSourceRepository, private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, @@ -36,10 +36,6 @@ class AlternativeTracksRepository( suspend fun resolveAlternatives(track: MetadataTrack): List { val trackId = track.id - streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached -> - logger.v { "Using cached alternatives for track $trackId" } - return cached - } val audioPlugin = pluginManager.selectedAudioPlugin.value if (audioPlugin == null) { @@ -47,6 +43,11 @@ class AlternativeTracksRepository( return emptyList() } + streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached -> + logger.v { "Using cached alternatives for track $trackId" } + return cached + } + val sources = runCatching { audioPlugin.use { audioAPI.getStreamsByTrack(track) } }.getOrElse { throwable -> @@ -59,24 +60,30 @@ class AlternativeTracksRepository( return emptyList() } - streamingUrlRepository.cacheAlternatives(trackId, sources) + streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId) logger.d { "Resolved ${sources.size} alternative sources for track $trackId" } return sources } suspend fun getActiveSourceId(track: MetadataTrack): String? { - return matchedTracksRepository.getTrackSource(track)?.id + val audioPlugin = pluginManager.selectedAudioPlugin.value ?: return null + return matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)?.id } suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) { val trackId = track.id + val audioPlugin = pluginManager.selectedAudioPlugin.value + if (audioPlugin == null) { + logger.w { "No audio plugin selected while selecting alternative for track $trackId" } + return + } val basic = when (source) { is AudioSource.Streamed -> source.toBasic() is AudioSource.Basic -> source } logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" } - matchedTracksRepository.saveTrackSource(track, basic) + matchedTracksRepository.saveTrackSource(track, basic, audioPlugin.pluginId) streamingUrlRepository.invalidateCachedStreamUrl(trackId) streamingUrlRepository.invalidateCachedAlternatives(trackId) cacheManager.invalidateCacheEntry(trackId) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt index 3dcda56c..d0170c1b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt @@ -24,23 +24,53 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.db.Database import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -class MatchedTracksRepository(private val database: Database) { - suspend fun getTrackSource(track: MetadataTrack): AudioSource.Basic? { +/** + * A matched [AudioSource.Basic] tagged with the id of the audio plugin that produced it. + * The [pluginId] tag lets [MatchedTracksRepository] detect and discard matches that were + * made by a plugin the user is no longer using, since a source id from one plugin (e.g. a + * YouTube video id) is meaningless - or worse, silently wrong - when handed to a different + * plugin's `getStreamsOfAudioSource`. + */ +@Serializable +data class PluginTaggedAudioSource( + val pluginId: String, + val source: AudioSource.Basic, +) + +/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */ +interface TrackSourceRepository { + suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? + suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) +} + +class MatchedTracksRepository(private val database: Database) : TrackSourceRepository { + /** + * Returns the previously matched source for [track], but only if it was produced by + * [pluginId]. A match saved by a different (or since-removed) plugin is treated as a + * cache miss rather than being handed to the wrong plugin. + */ + override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { return database.matchedTracksDataStore.data.map { prefs -> - val json = prefs[stringPreferencesKey(track.id)] - if (json != null) { - Json.decodeFromString(json as String) - } else { + val json = prefs[stringPreferencesKey(track.id)] ?: return@map null + val tagged = try { + Json.decodeFromString(json) + } catch (_: Exception) { + // Legacy entries stored before plugin-tagging was introduced; treat as + // unverifiable and force a fresh match instead of risking a wrong plugin. return@map null } + if (tagged.pluginId != pluginId) return@map null + tagged.source }.first() } - suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) { + override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { database.matchedTracksDataStore.edit { prefs -> - prefs[stringPreferencesKey(track.id)] = Json.encodeToString(source) + prefs[stringPreferencesKey(track.id)] = + Json.encodeToString(PluginTaggedAudioSource(pluginId, source)) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt index 00a215c5..7ba99621 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt @@ -26,8 +26,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import dev.krtirtho.spotube.modules.plugin.AudioPluginSource +import dev.krtirtho.spotube.modules.settings.UserSettingsSource import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.component.KoinComponent @@ -45,6 +45,15 @@ data class CachedStreamUrl( val selectedStream: AudioStream? = null, val preferredFormat: AudioFormat, val preferredQuality: AudioQuality, + // The id of the audio plugin that produced this entry. Guards against a stale entry + // (e.g. a YouTube video URL) being served after the user switches to a different + // audio plugin, which would otherwise play back completely unrelated content. + val pluginId: String, +) + +private data class CachedAlternatives( + val pluginId: String, + val sources: List, ) data class StreamInfo( @@ -55,21 +64,18 @@ data class StreamInfo( ) class StreamingUrlRepository( - private val pluginManager: PluginManager, + private val pluginManager: AudioPluginSource, private val audioPlayerQueue: AudioPlayerQueue, - private val matchedTracksRepository: MatchedTracksRepository, - private val settingsRepository: SettingsRepository, + private val matchedTracksRepository: TrackSourceRepository, + private val settingsRepository: UserSettingsSource, + private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds, ) : KoinComponent { - companion object { - private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds - } - private val logger by injectLogger() private val streamUrlCacheMutex = Mutex() private val streamUrlCache = mutableMapOf() private val alternativesCacheMutex = Mutex() - private val alternativesCache = mutableMapOf>() + private val alternativesCache = mutableMapOf() suspend fun resolveStreamInfo( trackId: String, @@ -95,19 +101,20 @@ class StreamingUrlRepository( val preferredFormat = settings.streamingMusicFormat val preferredQuality = settings.streamingMusicQuality - if (!forceRefresh) { - getCachedStreamUrl(trackId, preferredFormat, preferredQuality)?.let { cached -> - logger.v { "Using cached stream URL for track $trackId" } - return cached - } - } val audioPlugin = pluginManager.selectedAudioPlugin.value if (audioPlugin == null) { logger.w { "No audio plugin selected while resolving stream for track $trackId" } return null } - val source = matchedTracksRepository.getTrackSource(track) + if (!forceRefresh) { + getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> + logger.v { "Using cached stream URL for track $trackId" } + return cached + } + } + + val source = matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId) if (source != null) { logger.d { "Attempting stream resolution for track $trackId using cached source" } @@ -130,7 +137,7 @@ class StreamingUrlRepository( ) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() - cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) return info } } @@ -153,19 +160,19 @@ class StreamingUrlRepository( return null } - cacheAlternatives(trackId, sources) + cacheAlternatives(trackId, sources, audioPlugin.pluginId) val stream = runCatching { audioPlugin.use { sources.firstNotNullOfOrNull { src -> when (src) { is AudioSource.Streamed -> { - matchedTracksRepository.saveTrackSource(track, src.toBasic()) + matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId) src } is AudioSource.Basic -> { - matchedTracksRepository.saveTrackSource(track, src) + matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId) audioAPI.getStreamsOfAudioSource(src) .firstOrNull() } @@ -190,7 +197,7 @@ class StreamingUrlRepository( ) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() - cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) return info } return null @@ -207,10 +214,15 @@ class StreamingUrlRepository( trackId: String, preferredFormat: AudioFormat, preferredQuality: AudioQuality, + pluginId: String, ): StreamInfo? { val now = Clock.System.now().toEpochMilliseconds() return streamUrlCacheMutex.withLock { val cached = streamUrlCache[trackId] ?: return@withLock null + if (cached.pluginId != pluginId) { + logger.v { "Cached stream URL for track $trackId belongs to a different plugin; ignoring" } + return@withLock null + } if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) { return@withLock null } @@ -229,8 +241,9 @@ class StreamingUrlRepository( preferredQuality: AudioQuality, source: AudioSource.Streamed, selectedStream: AudioStream, + pluginId: String, ) { - val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS + val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs streamUrlCacheMutex.withLock { streamUrlCache[trackId] = CachedStreamUrl( url = info.url, @@ -242,6 +255,7 @@ class StreamingUrlRepository( selectedStream = selectedStream, preferredFormat = preferredFormat, preferredQuality = preferredQuality, + pluginId = pluginId, ) } } @@ -257,15 +271,21 @@ class StreamingUrlRepository( // Stream metadata is part of the same URL-cache entry and is removed with it. } - suspend fun getCachedAlternatives(trackId: String): List? { + /** Returns cached alternatives only if they were produced by [pluginId]. */ + suspend fun getCachedAlternatives(trackId: String, pluginId: String): List? { return alternativesCacheMutex.withLock { - alternativesCache[trackId] + val cached = alternativesCache[trackId] ?: return@withLock null + if (cached.pluginId != pluginId) { + logger.v { "Cached alternatives for track $trackId belong to a different plugin; ignoring" } + return@withLock null + } + cached.sources } } - suspend fun cacheAlternatives(trackId: String, sources: List) { + suspend fun cacheAlternatives(trackId: String, sources: List, pluginId: String) { alternativesCacheMutex.withLock { - alternativesCache[trackId] = sources + alternativesCache[trackId] = CachedAlternatives(pluginId, sources) logger.v { "Cached ${sources.size} alternative sources for track $trackId" } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt index a2be45f6..13829efa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt @@ -41,6 +41,7 @@ class BuiltInPluginService( private val pluginInfo: PluginEntry, ) : PluginService, KoinComponent { + override val pluginId: String = pluginInfo.id private val loggedInStateFlow = MutableStateFlow(false) override val loggedInFlow = loggedInStateFlow.asStateFlow() val servicesRegistry = mutableMapOf, ZiplineService>() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt index 36e1ac7d..6db523e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt @@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map, ZiplineService>) { } interface PluginService { + val pluginId: String val loggedInFlow: StateFlow suspend fun start() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt index d055b64e..ccd966a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt @@ -105,6 +105,8 @@ open class ZiplinePluginService( private val pluginInfo: PluginEntry, ) : PluginService, KoinComponent { + override val pluginId: String = pluginInfo.id + // QuickJS compile() uses deep C-level recursion on the native thread stack. // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 0fe5276e..4212733b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -69,11 +69,18 @@ interface PluginProvider { val selectedMetadataPlugin: StateFlow } +/** Narrow read-only view of the active audio plugin, used by consumers that only need to + * key caches or make calls against the currently selected audio plugin without depending + * on the full [PluginManager] (which also owns plugin installation/lifecycle machinery). */ +interface AudioPluginSource { + val selectedAudioPlugin: StateFlow +} + class PluginManager( val database: Database, val paths: Paths, -) : KoinComponent, PluginProvider { +) : KoinComponent, PluginProvider, AudioPluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -245,7 +252,7 @@ class PluginManager( override val selectedMetadataPlugin = filterSelectedPluginByType(PluginAbility.METADATA) - val selectedAudioPlugin = + override val selectedAudioPlugin = filterSelectedPluginByType(PluginAbility.AUDIO) val selectedLyricsPlugin = filterSelectedPluginByType(PluginAbility.LYRICS) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt index ac6a987f..8a31bd06 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt @@ -27,14 +27,21 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json -class SettingsRepository(private val database: Database) { +/** Narrow read-only view over the current [UserSettings], for consumers (like + * [dev.krtirtho.spotube.core.server.StreamingUrlRepository]) that only need to read the + * user's streaming preferences without depending on the full [SettingsRepository]. */ +interface UserSettingsSource { + val userSettings: StateFlow +} + +class SettingsRepository(private val database: Database) : UserSettingsSource { companion object { private val SETTINGS_KEY = stringPreferencesKey("user_settings") } val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> + override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { Json.decodeFromString(json as String) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt index 8a22546e..4b28e100 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -27,10 +27,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.server.CacheEntry import dev.krtirtho.spotube.core.server.CacheManager -import dev.krtirtho.spotube.core.server.MatchedTracksRepository import dev.krtirtho.spotube.core.server.StreamingUrlRepository +import dev.krtirtho.spotube.core.server.TrackSourceRepository import dev.krtirtho.spotube.core.server.normalizeManifestUrl import dev.krtirtho.spotube.core.server.selectPreferredAudioStream +import dev.krtirtho.spotube.modules.plugin.AudioPluginSource import dev.krtirtho.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -72,10 +73,11 @@ data class PlayerOptionsUiState( class PlayerOptionsViewModel( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val matchedTracksRepository: MatchedTracksRepository, + private val matchedTracksRepository: TrackSourceRepository, private val streamingUrlRepository: StreamingUrlRepository, private val cacheManager: CacheManager, private val settingsRepository: SettingsRepository, + private val pluginManager: AudioPluginSource, ) : ViewModel() { private val _uiState = MutableStateFlow(PlayerOptionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -155,7 +157,8 @@ class PlayerOptionsViewModel( runCatching { val settings = settingsRepository.userSettings.value withContext(Dispatchers.IO) { - val matchedSource = matchedTracksRepository.getTrackSource(track) + val pluginId = pluginManager.selectedAudioPlugin.value?.pluginId + val matchedSource = pluginId?.let { matchedTracksRepository.getTrackSource(track, it) } val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id) val localCacheEntry = if (settings.enableMusicCaching) { cacheManager.findCachedEntry(track.id)?.second diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt index 40891c1e..0a204cfd 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt @@ -33,7 +33,8 @@ class FakeMetadataTrackAPI( } class FakePluginService( - private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI() + private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), + override val pluginId: String = "fake-plugin", ) : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt new file mode 100644 index 00000000..8b0fbf82 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -0,0 +1,556 @@ +package dev.krtirtho.spotube.core.server + +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.modules.settings.UserSettings +import co.touchlab.kermit.Logger +import kotlinx.coroutines.test.runTest +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Behavioral / caching tests for [StreamingUrlRepository]: the in-memory stream-url cache, + * the matched-source lookup, and the interaction between the two when the active audio + * plugin changes. + * + * These specifically target the "wrong track plays" class of bug: a stale match or cached + * URL from one plugin (or one track) being silently reused for a different plugin/track. + */ +class StreamingUrlRepositoryBehaviorTest { + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun track(id: String, title: String = "Track $id") = MetadataTrack( + id = id, + title = title, + durationMs = 200_000, + trackNumber = 1, + discNumber = 1, + artists = emptyList(), + album = null, + thumbnails = null, + explicit = false, + popularity = null, + isrcCode = null, + externalUri = null, + ) + + private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic( + id = id, + title = "source-$id", + artist = null, + album = null, + thumbnails = emptyList(), + externalUri = null, + confidence = confidence, + ) + + private fun streamedSource( + sourceId: String, + streams: List, + confidence: Float = 1f, + ) = AudioSource.Streamed( + id = sourceId, + title = "source-$sourceId", + artist = null, + album = null, + thumbnails = emptyList(), + externalUri = null, + confidence = confidence, + streams = streams, + ) + + private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy( + url = url, + codec = "opus", + container = "webm", + protocol = StreamProtocol.PROGRESSIVE, + bitrate = bitrate, + ) + + private class Harness(cacheTtlMs: Long = 30_000) { + val queue = FakeStreamingAudioPlayerQueue() + val matchedTracksRepository = FakeTrackSourceRepository() + val settings = FakeUserSettingsSource() + val pluginSource = FakeAudioPluginSource() + val repository = StreamingUrlRepository( + pluginManager = pluginSource, + audioPlayerQueue = queue, + matchedTracksRepository = matchedTracksRepository, + settingsRepository = settings, + streamUrlCacheTtlMs = cacheTtlMs, + ) + + fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { + val service = FakeAudioPluginService(pluginId, audioAPI) + pluginSource.setPlugin(service) + return service + } + } + + // ---------- basic resolution ---------- + + @Test + fun `resolves and returns a stream via track lookup when nothing is cached`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertNotNull(info) + assertEquals("https://example.com/a.opus", info.url) + } + + @Test + fun `returns null when no audio plugin is selected`() = runTest { + val h = Harness() + val info = h.repository.resolveStreamInfo(track("t1")) + assertNull(info) + } + + @Test + fun `returns null when the plugin yields no sources`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { emptyList() } + + assertNull(h.repository.resolveStreamInfo(track("t1"))) + } + + @Test + fun `resolveStreamInfo by trackId looks up the matching queue entry`() = runTest { + val h = Harness() + val t = track("t1") + h.queue.setQueue(listOf(QueueEntry.StreamingTrack(track = t, url = ""))) + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(trackId = "t1") + + assertNotNull(info) + assertEquals("https://example.com/a.opus", info.url) + } + + @Test + fun `resolveStreamInfo by trackId returns null when track is not queued`() = runTest { + val h = Harness() + h.selectPlugin("pluginA") + assertNull(h.repository.resolveStreamInfo(trackId = "not-queued")) + } + + // ---------- in-memory URL cache ---------- + + @Test + fun `second resolution for the same track reuses the cache instead of calling the plugin again`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.resolveStreamInfo(t) + + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `forceRefresh bypasses the cache and re-resolves`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + var counter = 0 + plugin.audioAPI.onGetStreamsByTrack = { + counter++ + listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus")))) + } + + val first = h.repository.resolveStreamInfo(t) + val second = h.repository.resolveStreamInfo(t, forceRefresh = true) + + assertEquals("https://example.com/1.opus", first?.url) + assertEquals("https://example.com/2.opus", second?.url) + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `changing the preferred format invalidates the cached url`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf( + streamedSource( + "src1", + listOf( + stream("https://example.com/opus.webm"), + AudioStream.Lossy( + url = "https://example.com/aac.mp4", + codec = "aac", + container = "mp4", + bitrate = 256_000, + ), + ) + ) + ) + } + + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/opus.webm", first?.url) + + h.settings.update { + copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "aac", container = "mp4")) + } + + val second = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/aac.mp4", second?.url) + // Preference change forced a second real lookup rather than serving the stale URL. + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `changing the preferred quality invalidates the cached url`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf( + streamedSource( + "src1", + listOf(stream("https://example.com/128.opus", 128_000), stream("https://example.com/320.opus", 320_000)) + ) + ) + } + + h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(128_000)) } + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/128.opus", first?.url) + + h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(320_000)) } + val second = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/320.opus", second?.url) + } + + @Test + fun `cached url expires after the configured ttl`() = kotlinx.coroutines.runBlocking { + val h = Harness(cacheTtlMs = 10) + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + var counter = 0 + plugin.audioAPI.onGetStreamsByTrack = { + counter++ + listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus")))) + } + + h.repository.resolveStreamInfo(t) + kotlinx.coroutines.delay(50) + h.repository.resolveStreamInfo(t) + + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `invalidateCachedStreamUrl removes the cached entry unconditionally when no url is given`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.invalidateCachedStreamUrl(t.id) + h.repository.resolveStreamInfo(t) + + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `invalidateCachedStreamUrl with a mismatching url is a no-op`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.invalidateCachedStreamUrl(t.id, url = "https://not-the-cached-url") + h.repository.resolveStreamInfo(t) + + // Cache entry survived because the supplied url didn't match; plugin isn't hit again. + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + // ---------- matched-source fast path ---------- + + @Test + fun `a previously matched source from the same plugin is reused via getStreamsOfAudioSource`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/matched.opus")))) + } + plugin.audioAPI.onGetStreamsByTrack = { + error("should not fall back to track lookup when a matched source resolves") + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/matched.opus", info?.url) + assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) + assertEquals(0, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `THE BUG - a source matched by a previous plugin is never handed to a newly selected plugin`() = runTest { + val h = Harness() + val t = track("t1") + + // The user matched this track while "pluginA" (e.g. a YouTube-backed plugin) was active. + // "yt-video-123" is meaningful *only* to pluginA. + h.matchedTracksRepository.seed(t, basicSource("yt-video-123"), pluginId = "pluginA") + + // The user then switches the active audio plugin to "pluginB" (a different backend). + val pluginB = h.selectPlugin("pluginB") + pluginB.audioAPI.onGetStreamsOfAudioSource = { + error( + "pluginB must never receive a source id matched by a different plugin " + + "(id=${it.id}); this is exactly how playback of the wrong track happens." + ) + } + pluginB.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("pluginB-native-source", listOf(stream("https://example.com/correct.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + // pluginB never saw the stale "yt-video-123" id... + assertEquals(0, pluginB.audioAPI.getStreamsOfAudioSourceCallCount) + // ...and instead correctly fell back to a fresh, pluginB-native track lookup. + assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount) + assertEquals("https://example.com/correct.opus", info?.url) + } + + @Test + fun `a cached stream url from a previous plugin is not served after switching plugins`() = runTest { + val h = Harness() + val t = track("t1") + + val pluginA = h.selectPlugin("pluginA") + pluginA.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("srcA", listOf(stream("https://example.com/pluginA.opus")))) + } + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/pluginA.opus", first?.url) + + val pluginB = h.selectPlugin("pluginB") + pluginB.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("srcB", listOf(stream("https://example.com/pluginB.opus")))) + } + val second = h.repository.resolveStreamInfo(t) + + // Must re-resolve via pluginB rather than replaying pluginA's cached URL. + assertEquals("https://example.com/pluginB.opus", second?.url) + assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `matched source is saved and tagged with the resolving plugin id`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + + val saved = h.matchedTracksRepository.rawEntryFor(t.id) + assertNotNull(saved) + assertEquals("pluginA", saved.pluginId) + assertEquals("src1", saved.source.id) + } + + @Test + fun `matched source is looked up but ignored when the format preference has no matching stream`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + // Matched source resolves to a stream, so preferred-format mismatch is irrelevant here; + // this documents that selection still happens on top of the matched-source stream list. + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/only.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/only.opus", info?.url) + } + + @Test + fun `falls back to track lookup when the matched source resolves no streams`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() } + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("fresh", listOf(stream("https://example.com/fresh.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/fresh.opus", info?.url) + assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `track lookup persists a Basic source by resolving its streams before caching`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { listOf(basicSource("basic-1")) } + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/resolved.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/resolved.opus", info?.url) + assertEquals("basic-1", h.matchedTracksRepository.rawEntryFor(t.id)?.source?.id) + } + + @Test + fun `distinct tracks never share cache entries even with concurrent resolution`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { t -> + listOf(streamedSource("src-${t.id}", listOf(stream("https://example.com/${t.id}.opus")))) + } + + val t1 = track("t1") + val t2 = track("t2") + + val info1 = h.repository.resolveStreamInfo(t1) + val info2 = h.repository.resolveStreamInfo(t2) + + assertEquals("https://example.com/t1.opus", info1?.url) + assertEquals("https://example.com/t2.opus", info2?.url) + assertTrue(info1?.url != info2?.url) + } + + // ---------- alternatives cache ---------- + + @Test + fun `getCachedAlternatives returns null before anything is cached`() = runTest { + val h = Harness() + assertNull(h.repository.getCachedAlternatives("t1", "pluginA")) + } + + @Test + fun `alternatives resolved via track lookup are cached and retrievable`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + val candidates = listOf(basicSource("alt-1"), basicSource("alt-2")) + plugin.audioAPI.onGetStreamsByTrack = { candidates } + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/${it.id}.opus")))) + } + + h.repository.resolveStreamInfo(t) + + val cached = h.repository.getCachedAlternatives(t.id, "pluginA") + assertNotNull(cached) + assertEquals(2, cached.size) + } + + @Test + fun `alternatives cached by one plugin are not visible to a different plugin`() = runTest { + val h = Harness() + val t = track("t1") + val pluginA = h.selectPlugin("pluginA") + pluginA.audioAPI.onGetStreamsByTrack = { listOf(basicSource("alt-1")) } + pluginA.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/a.opus")))) + } + h.repository.resolveStreamInfo(t) + + assertNotNull(h.repository.getCachedAlternatives(t.id, "pluginA")) + assertNull(h.repository.getCachedAlternatives(t.id, "pluginB")) + } + + @Test + fun `cacheAlternatives followed by invalidateCachedAlternatives clears the entry`() = runTest { + val h = Harness() + h.repository.cacheAlternatives("t1", listOf(basicSource("alt-1")), "pluginA") + assertNotNull(h.repository.getCachedAlternatives("t1", "pluginA")) + + h.repository.invalidateCachedAlternatives("t1") + + assertNull(h.repository.getCachedAlternatives("t1", "pluginA")) + } + + // ---------- getCachedStreamUrlEntry ---------- + + @Test + fun `getCachedStreamUrlEntry returns null when nothing has been cached`() = runTest { + val h = Harness() + assertNull(h.repository.getCachedStreamUrlEntry("t1")) + } + + @Test + fun `getCachedStreamUrlEntry never fetches - it only reflects the existing cache`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + assertNull(h.repository.getCachedStreamUrlEntry(t.id)) + h.repository.resolveStreamInfo(t) + val entry = h.repository.getCachedStreamUrlEntry(t.id) + + assertNotNull(entry) + assertEquals("https://example.com/a.opus", entry.url) + assertEquals("pluginA", entry.pluginId) + // Fetching the entry doesn't call the plugin again. + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt new file mode 100644 index 00000000..27e5550f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -0,0 +1,137 @@ +package dev.krtirtho.spotube.core.server + +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.zipline.PluginService +import dev.krtirtho.spotube.core.zipline.PluginServiceScope +import dev.krtirtho.spotube.modules.plugin.AudioPluginSource +import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** In-memory [AudioAPI] whose behaviour for each call is fully scripted by the test. */ +class FakeAudioAPI( + override val supportedQualities: List = emptyList(), + var onGetStreamsByTrack: suspend (MetadataTrack) -> List = { emptyList() }, + var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }, +) : AudioAPI { + var getStreamsByTrackCallCount = 0 + var getStreamsOfAudioSourceCallCount = 0 + val getStreamsOfAudioSourceCalls = mutableListOf() + + override suspend fun getStreamsByTrack(track: MetadataTrack): List { + getStreamsByTrackCallCount++ + return onGetStreamsByTrack(track) + } + + override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { + getStreamsOfAudioSourceCallCount++ + getStreamsOfAudioSourceCalls.add(source) + return onGetStreamsOfAudioSource(source) + } +} + +/** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */ +class FakeAudioPluginService( + override val pluginId: String, + val audioAPI: FakeAudioAPI = FakeAudioAPI(), +) : PluginService { + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val scope = PluginServiceScope(mapOf(AudioAPI::class to audioAPI)) + return scope.block() + } +} + +class FakeAudioPluginSource( + initialPlugin: PluginService? = null +) : AudioPluginSource { + private val _selectedAudioPlugin = MutableStateFlow(initialPlugin) + override val selectedAudioPlugin: StateFlow = _selectedAudioPlugin + + fun setPlugin(service: PluginService?) { + _selectedAudioPlugin.value = service + } +} + +/** In-memory [TrackSourceRepository], mirroring the plugin-scoped persistence contract of + * the real [MatchedTracksRepository] without touching disk. */ +class FakeTrackSourceRepository : TrackSourceRepository { + private val store = mutableMapOf() + var getCallCount = 0 + var saveCallCount = 0 + + override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { + getCallCount++ + val tagged = store[track.id] ?: return null + if (tagged.pluginId != pluginId) return null + return tagged.source + } + + override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + saveCallCount++ + store[track.id] = PluginTaggedAudioSource(pluginId, source) + } + + fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId] + + fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + store[track.id] = PluginTaggedAudioSource(pluginId, source) + } +} + +class FakeUserSettingsSource(initial: UserSettings = UserSettings()) : UserSettingsSource { + private val _userSettings = MutableStateFlow(initial) + override val userSettings: StateFlow = _userSettings + + fun update(transform: UserSettings.() -> UserSettings) { + _userSettings.value = _userSettings.value.transform() + } +} + +/** Bare-bones [AudioPlayerQueue] that only backs [queueFlow]; every other member is unused + * by [StreamingUrlRepository] and throws if accidentally exercised. */ +class FakeStreamingAudioPlayerQueue : AudioPlayerQueue { + private val _queueFlow = MutableStateFlow>(emptyList()) + override val queueFlow: StateFlow> = _queueFlow + override val currentQueueEntryFlow: StateFlow = MutableStateFlow(null) + override val currentCollectionEntryFlow: StateFlow = MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + fun setQueue(entries: List) { + _queueFlow.value = entries + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?, + ) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = + error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = error("not used") + override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used") + override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used") + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used") + override suspend fun reloadCurrent() = error("not used") + override suspend fun clear() = error("not used") + override suspend fun getQueue(): List = _queueFlow.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = null + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() +} From 78168d269cca8a810d3fe9c11a93c77a0478659d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 18:47:29 +0600 Subject: [PATCH 022/132] feat: implement shuffle play functionality for albums, playlists, and saved tracks --- .../core/playback/CollectionPlaybackHelper.kt | 18 +++++++++--------- .../core/remote/RemoteControlHandler.kt | 16 ++++++++++------ .../core/remote/RemoteControlProtocol.kt | 4 ++-- .../core/remote/RemotePlaybackController.kt | 14 ++++++++------ .../spotube/modules/album/AlbumScreen.kt | 2 +- .../spotube/modules/album/AlbumViewModel.kt | 5 +++++ .../spotube/modules/playlist/PlaylistScreen.kt | 2 +- .../modules/playlist/PlaylistViewModel.kt | 5 +++++ .../modules/saved_tracks/SavedTracksScreen.kt | 2 +- .../saved_tracks/SavedTracksViewModel.kt | 11 ++++++++++- gradle/libs.versions.toml | 2 +- 11 files changed, 53 insertions(+), 28 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index da55fb38..6d22e903 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -36,9 +36,9 @@ class CollectionPlaybackHelper( private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, ) { - suspend fun playAlbum(albumId: String) { - if (audioPlayerQueue.isAlbumPlaying(albumId)) return - val entries = fetchAllAlbumTracks(albumId) + suspend fun playAlbum(albumId: String, shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return + val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it } if (entries.isNotEmpty()) { audioPlayerQueue.load( entries = entries, @@ -91,9 +91,9 @@ class CollectionPlaybackHelper( ) } - suspend fun playPlaylist(playlistId: String) { - if (audioPlayerQueue.isPlaylistPlaying(playlistId)) return - val entries = fetchAllPlaylistTracks(playlistId) + suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isPlaylistPlaying(playlistId)) return + val entries = fetchAllPlaylistTracks(playlistId).let { if (shuffle) it.shuffled() else it } if (entries.isNotEmpty()) { audioPlayerQueue.load( entries = entries, @@ -146,9 +146,9 @@ class CollectionPlaybackHelper( ) } - suspend fun playSavedTracks() { - if (audioPlayerQueue.isSavedTracksPlaying()) return - val entries = fetchAllSavedTracks() + suspend fun playSavedTracks(shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isSavedTracksPlaying()) return + val entries = fetchAllSavedTracks().let { if (shuffle) it.shuffled() else it } if (entries.isNotEmpty()) { audioPlayerQueue.load( entries = entries, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 602fc9f1..9307ea65 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -172,7 +172,7 @@ class RemoteControlHandler( private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { when (val command = envelope.command) { is RemoteControlCommand.Play -> { - handleCollectionSource(command.source, RemoteCollectionAction.Play) + handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle) } is RemoteControlCommand.Pause -> { audioPlayer.pause() @@ -297,13 +297,17 @@ class RemoteControlHandler( * Resolves a `spotube://` collection source URI (playlist/album/artist top * tracks/saved tracks) and applies the requested action on the remote queue. */ - private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) { + private suspend fun handleCollectionSource( + source: String, + action: RemoteCollectionAction, + shuffle: Boolean = false, + ) { logger.d { "Remote collection $action for source: $source" } when { source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) when (action) { - RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id) + RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle) RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) } @@ -312,7 +316,7 @@ class RemoteControlHandler( source.startsWith(COLLECTION_ALBUM_PREFIX) -> { val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) when (action) { - RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id) + RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle) RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) } @@ -329,7 +333,7 @@ class RemoteControlHandler( source == COLLECTION_SAVED_TRACKS -> { when (action) { - RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks() + RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle) RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() RemoteCollectionAction.PlayNext -> { // Saved tracks "play next" is not supported; add to queue instead @@ -467,4 +471,4 @@ enum class ConnectionRequestResponse { Allow, AllowAlways, Deny, -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index fc51834e..e2ab1bd9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable sealed class RemoteControlCommand { @Serializable @SerialName("play") - data class Play(val source: String) : RemoteControlCommand() + data class Play(val source: String, val shuffle: Boolean = false) : RemoteControlCommand() @Serializable @SerialName("pause") @@ -147,4 +147,4 @@ data class RemoteQueueEntry( val album: String?, val coverUrl: String?, val durationMs: Long, -) \ No newline at end of file +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 40602ea9..57c89e82 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -64,6 +64,7 @@ sealed interface PlaybackDestinationRequest { val type: RemoteCollectionType, val id: String, val startTrack: MetadataTrack? = null, + val shuffle: Boolean = false, ) : PlaybackDestinationRequest data class Track( @@ -112,8 +113,9 @@ class RemotePlaybackController( id: String, title: String, startTrack: MetadataTrack? = null, + shuffle: Boolean = false, ) { - request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack)) + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle)) } fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { @@ -239,7 +241,7 @@ class RemotePlaybackController( if (startTrack != null) { collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) } else { - collectionPlaybackHelper.playPlaylist(request.id) + collectionPlaybackHelper.playPlaylist(request.id, request.shuffle) } } @@ -252,7 +254,7 @@ class RemotePlaybackController( if (startTrack != null) { collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) } else { - collectionPlaybackHelper.playAlbum(request.id) + collectionPlaybackHelper.playAlbum(request.id, request.shuffle) } } @@ -271,7 +273,7 @@ class RemotePlaybackController( if (startTrack != null) { collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) } else { - collectionPlaybackHelper.playSavedTracks() + collectionPlaybackHelper.playSavedTracks(request.shuffle) } } @@ -346,7 +348,7 @@ class RemotePlaybackController( RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" } when (request.action) { - PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source) + PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle) PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) } @@ -385,4 +387,4 @@ class RemotePlaybackController( album?.id == other.album?.id && artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index db76747f..6b2ac18d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -78,7 +78,7 @@ fun AlbumScreen( } }, onPlay = viewModel::playAlbum, - onShufflePlay = {}, + onShufflePlay = viewModel::shufflePlayAlbum, onAddToQueue = viewModel::addAlbumToQueue, isPlaying = isAlbumPlaying, isFollowing = savedAlbumIds.contains(albumId), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index 3da95baa..d27d9421 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -224,6 +224,11 @@ class AlbumViewModel( remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) } + fun shufflePlayAlbum() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title, shuffle = true) + } + fun addAlbumToQueue() { val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index b48f1253..a472ac93 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -111,7 +111,7 @@ fun PlaylistScreen( ownerImageURL = ownerImageURL, onOwnerClick = {}, onPlay = viewModel::playPlaylist, - onShufflePlay = {}, + onShufflePlay = viewModel::shufflePlayPlaylist, onAddToQueue = viewModel::addPlaylistToQueue, isPlaying = isPlaying, isFollowing = savedPlaylistIds.contains(playlistId), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index d1ef1088..5a4c1076 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -230,6 +230,11 @@ class PlaylistViewModel( remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) } + fun shufflePlayPlaylist() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title, shuffle = true) + } + fun addPlaylistToQueue() { val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 191923a0..a7640ebd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -70,7 +70,7 @@ fun SavedTracksScreen( ownerImageURL = null, onOwnerClick = {}, onPlay = viewModel::playSavedTracks, - onShufflePlay = {}, + onShufflePlay = viewModel::shufflePlaySavedTracks, onAddToQueue = viewModel::addSavedTracksToQueue, isPlaying = isPlaying, isFollowing = false, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 1025bdbd..bf8fafa4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -206,6 +206,15 @@ class SavedTracksViewModel( remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } + fun shufflePlaySavedTracks() { + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.SavedTracks, + SAVED_TRACKS_COLLECTION_ID, + "Saved Tracks", + shuffle = true, + ) + } + fun addSavedTracksToQueue() { remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } @@ -377,4 +386,4 @@ fun rememberIsSavedTracks( } return state -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0951e49..2e78d2a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ materialKolor = "4.1.1" mqttClient = "2.1.1" mqttBuffer = "6.30.8" murmurhash = "0.4.2" -newpipeextractor = "v0.26.2" +newpipeextractor = "v0.26.5" newpipeExtractorKmp = "1.3.0" nucleusNucleusApplication = "2.4.6" semver = "2.1.0" From 86ec117cfd5df23b966afebddf618c67b80380b8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 21:07:19 +0600 Subject: [PATCH 023/132] feat(navigation): refactor sidebar and bottombar navigation structure with new tab and group models --- .../kotlin/dev/krtirtho/spotube/App.kt | 94 ++++++--- .../spotube/modules/artist/ArtistScreen.kt | 56 ++++-- .../spotube/modules/lyrics/LyricsScreen.kt | 82 ++++---- .../spotube/modules/shell/AppBottombar.kt | 80 ++++---- .../spotube/modules/shell/AppSidebar.kt | 190 ++++++++++-------- .../iconsax/IconsaxMirrorScreenRegular.kt | 89 ++++++++ .../spotube/resources/iconsax/IconsaxSound.kt | 29 +-- .../resources/iconsax/IconsaxSoundTwotone.kt | 97 +++++++++ gradle/gradle-daemon-jvm.properties | 4 +- 9 files changed, 483 insertions(+), 238 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSoundTwotone.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index ee165e9b..ae862050 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -40,56 +40,91 @@ import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme +import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.shell.AppShell import dev.krtirtho.spotube.modules.webview.WebViewScreen import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxHome import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone +import dev.krtirtho.spotube.resources.iconsax.IconsaxSound +import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone +import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.compose.koinInject import org.koin.compose.navigation3.koinEntryProvider import org.koin.core.annotation.KoinExperimentalAPI -data class TabItem( - val title: String, - val icon: ImageVector, - val activeIcon: ImageVector, - val route: Routes +interface NavigationItem { + data class Tab( + val title: String, val icon: ImageVector, val route: Routes, + val data: T? = null + ) : NavigationItem + + data class Group( + val title: String, val items: List> + ) : NavigationItem +} + +private val tabs = listOf( + NavigationItem.Tab("Home", Iconsax.IconsaxHome, Routes.Home), + NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search), ) -val tabs = listOf( - TabItem( - "Home", - Iconsax.IconsaxHomeBroken, - Iconsax.IconsaxHome, - Routes.Home - ), - TabItem( - "Search", - Iconsax.IconsaxSearchBroken, - Iconsax.IconsaxSearch, - Routes.Search - ), - TabItem( +val sidebarTabs = tabs + listOf( + NavigationItem.Group( "Library", - Iconsax.IconsaxMusicLibraryOutline, - Iconsax.IconsaxMusicLibrary, - Routes.Library + items = listOf( + NavigationItem.Tab( + "Playlists", + Iconsax.IconsaxMusicDashboard, + Routes.Library, + LibraryTab.Playlists + ), + NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), + NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists), + ) ), - TabItem( - "Settings", - Iconsax.IconsaxSettingTwotone, - Iconsax.IconsaxSetting2, - Routes.Settings - ) + NavigationItem.Group( + "On Device", + items = listOf( + NavigationItem.Tab( + "Local Tracks", + Iconsax.IconsaxMusicLibrary, + Routes.Library, + LibraryTab.LocalTracks + ), + NavigationItem.Tab( + "Downloads", + Iconsax.IconsaxDirectboxReceive, + Routes.Library, + LibraryTab.Downloads + ), + ) + ), + NavigationItem.Group( + "Connect", + items = listOf( + NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), + NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam), + ), + ), +) +val bottombarTabs = tabs + listOf( + NavigationItem.Tab("Library", Iconsax.IconsaxMusicLibrary, Routes.Library), + NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings) ) @OptIn( @@ -105,8 +140,7 @@ fun App( val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) val navigationState = rememberNavigationState( - startRoute = Routes.Home, - topLevelRoutes = TOP_LEVEL_ROUTES + startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES ) val navigator = remember { Navigator(navigationState) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index a38c36e6..ca2d0458 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -38,10 +38,16 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -81,6 +87,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -134,7 +141,8 @@ fun ArtistScreen( state = listState, modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(innerPadding) + .padding(bottom = LocalAppShellBottomInset.current + 24.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { item { @@ -495,6 +503,7 @@ private fun ArtistMeta( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ArtistHeaderActions( isSaved: Boolean, @@ -508,7 +517,12 @@ private fun ArtistHeaderActions( ) { if (isSaved) { SecondaryButton(onClick = onFollowClick) { - Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) + Text( + "Following", + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } else { PrimaryButton(onClick = onFollowClick) { @@ -516,20 +530,30 @@ private fun ArtistHeaderActions( } } - if (isBlacklisted) { - SecondaryIconButton(onClick = onBlacklistClick) { - Icon( - imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Remove from blacklist", - tint = MaterialTheme.colorScheme.error, - ) - } - } else { - SecondaryIconButton(onClick = onBlacklistClick) { - Icon( - imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Add to blacklist", - ) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + PlainTooltip { + Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist") + } + }, + state = rememberTooltipState() + ) { + if (isBlacklisted) { + SecondaryIconButton(onClick = onBlacklistClick) { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = "Remove from blacklist", + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + SecondaryIconButton(onClick = onBlacklistClick) { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = "Add to blacklist", + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index f93008e2..b1a0cbd4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -54,6 +54,8 @@ import androidx.compose.ui.unit.sp import compose.icons.FeatherIcons import compose.icons.feathericons.X import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import kotlinx.coroutines.launch @@ -70,6 +72,47 @@ fun LyricsScreen( val scope = rememberCoroutineScope() Scaffold( + topBar = { + ApplicationMainBar( + backButton = onClose == null, + title = { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + if (onClose != null) { + IconButton( + onClick = onClose, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Icon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = "Close" + ) + } + } + Row( + modifier = Modifier + .align(Alignment.Center) + .clip(RoundedCornerShape(50.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(10.dp, 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LyricsModeTab( + label = "Synced", + isSelected = uiState.mode == LyricType.SYNCED, + onClick = { viewModel.setMode(LyricType.SYNCED) } + ) + LyricsModeTab( + label = "Plain", + isSelected = uiState.mode == LyricType.STATIC, + onClick = { viewModel.setMode(LyricType.STATIC) } + ) + } + } + } + ) + }, modifier = modifier, ) { innerPadding -> Column( @@ -77,46 +120,9 @@ fun LyricsScreen( .fillMaxSize() .padding(innerPadding) .padding(horizontal = 16.dp) + .padding(bottom = LocalAppShellBottomInset.current + 24.dp) ) { Spacer(modifier = Modifier.height(16.dp)) - - Box( - modifier = Modifier.fillMaxWidth(), - ) { - if (onClose != null) { - IconButton( - onClick = onClose, - modifier = Modifier.align(Alignment.CenterStart) - ) { - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = "Close" - ) - } - } - Row( - modifier = Modifier - .align(Alignment.Center) - .clip(RoundedCornerShape(50.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(10.dp, 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LyricsModeTab( - label = "Synced", - isSelected = uiState.mode == LyricType.SYNCED, - onClick = { viewModel.setMode(LyricType.SYNCED) } - ) - LyricsModeTab( - label = "Plain", - isSelected = uiState.mode == LyricType.STATIC, - onClick = { viewModel.setMode(LyricType.STATIC) } - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - when { uiState.isLoading -> { Box( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt index 4603e3e7..eaabb5e9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt @@ -42,11 +42,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.NavigationItem +import dev.krtirtho.spotube.bottombarTabs import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator -import dev.krtirtho.spotube.tabs @Composable fun AppBottombar( @@ -68,46 +69,47 @@ fun AppBottombar( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - tabs.forEach { (label, icon, activeIcon, screen) -> - val selected = navigationState.topLevelRoute == screen - val labelColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - Column( - modifier = Modifier - .clip(CircleShape) - .clickable { navigator.navigate(screen) } - .padding(8.dp) - .size(56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Box { - Icon( - imageVector = activeIcon, - contentDescription = label, - tint = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - if (screen == Routes.Library) { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } + bottombarTabs.filterIsInstance>() + .forEach { tab -> + val selected = navigationState.topLevelRoute == tab.route + val labelColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Column( + modifier = Modifier + .clip(CircleShape) + .clickable { navigator.navigate(tab.route) } + .padding(8.dp) + .size(56.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box { + Icon( + imageVector = tab.icon, + contentDescription = tab.title, + tint = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + if (tab.route == Routes.Library) { + DownloadBadgeIndicator( + modifier = Modifier.align(Alignment.TopEnd) + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = tab.title, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, + color = labelColor, + ) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, - color = labelColor, - ) } - } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 0c4a3c78..36ede564 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -29,12 +29,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -55,6 +59,7 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.hazeEffect +import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes @@ -64,15 +69,15 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.copyPadding import dev.krtirtho.spotube.core.ui.base.copyShape +import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken -import dev.krtirtho.spotube.resources.iconsax.User -import dev.krtirtho.spotube.tabs +import dev.krtirtho.spotube.sidebarTabs import org.jetbrains.compose.resources.Font import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res @@ -90,6 +95,7 @@ fun AppSidebar( val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) val currentLibraryTab by libraryState.currentTab.collectAsState() val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) + val listState = rememberLazyListState() Column( modifier = modifier @@ -106,95 +112,107 @@ fun AppSidebar( horizontalAlignment = Alignment.CenterHorizontally ) { Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), - horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { - Text( - text = "Spotube", - style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily( - listOf( - Font(Res.font.cookie_regular, weight = FontWeight.Normal) - ) - ) - ), - color = MaterialTheme.colorScheme.onSurface - ) - } - GhostIconButton( - onClick = { expanded = !expanded } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken, - contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - tabs.forEach { (label, icon, activeIcon, screen) -> - val selected = navigationState.topLevelRoute == screen - - if (screen == Routes.Library) { - AnimatedVisibility(visible = expanded) { + AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { Text( - text = "LIBRARY", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp) + text = "Spotube", + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = FontFamily( + listOf( + Font(Res.font.cookie_regular, weight = FontWeight.Normal) + ) + ) + ), + color = MaterialTheme.colorScheme.onSurface ) } - - LibraryTab.entries.forEach { tab -> - val isSubSelected = selected && currentLibraryTab == tab - - SidebarItem( - label = tab.title, - activeIcon = tab.icon, - onClick = { - libraryState.currentTab.value = tab - navigator.navigate(screen) - }, - selected = isSubSelected, - expanded = expanded, - showDownloadBadge = tab == LibraryTab.Downloads, + GhostIconButton( + onClick = { expanded = !expanded } + ) { + Icon( + imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken, + contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar", + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } else { - SidebarItem( - label = label, - activeIcon = activeIcon, - onClick = { navigator.navigate(screen) }, - selected = selected, - expanded = expanded, + } + + Spacer(modifier = Modifier.height(8.dp)) + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn(state = listState) { + items(sidebarTabs.size) { index -> + when (val tab = sidebarTabs[index]) { + is NavigationItem.Group<*> -> { + androidx.compose.animation.AnimatedVisibility(visible = expanded) { + Text( + text = tab.title.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + ) + } + tab.items.forEach { + val selected = navigationState.topLevelRoute == it.route + SidebarItem( + label = it.title, + activeIcon = it.icon, + onClick = { + if (it.route == Routes.Library) { + libraryState.currentTab.value = + it.data as LibraryTab + } + navigator.navigate(it.route) + }, + selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected, + expanded = expanded, + showDownloadBadge = it.route == Routes.Library + && it.data == LibraryTab.Downloads + ) + } + + } + + is NavigationItem.Tab<*> -> { + val selected = navigationState.topLevelRoute == tab.route + SidebarItem( + label = tab.title, + activeIcon = tab.icon, + onClick = { + navigator.navigate(tab.route) + }, + selected = selected, + expanded = expanded, + ) + } + } + } + } + VerticalScrollbar( + listState = listState, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() ) } } - } - + HorizontalDivider() SidebarItem( - label = "Devices", - activeIcon = Iconsax.IconsaxMirroringScreen, - onClick = { navigator.navigate(Routes.Devices) }, - selected = false, + label = "Settings", + activeIcon = Iconsax.IconsaxSetting2, + onClick = { + navigator.navigate(Routes.Settings) + }, + selected = navigationState.topLevelRoute == Routes.Settings, expanded = expanded, ) - SidebarItem( - label = "Group Jam", - activeIcon = Iconsax.User, - onClick = { navigator.navigate(Routes.Jam) }, - selected = false, - expanded = expanded, - ) - Spacer(modifier = Modifier.height(120.dp)) } } @@ -209,7 +227,7 @@ fun SidebarItem( showDownloadBadge: Boolean = false, ) { val itemContent: @Composable RowScope.() -> Unit = { - Box(modifier = Modifier.size(20.dp)) { + Box(modifier = Modifier.size(16.dp)) { Icon( imageVector = activeIcon, contentDescription = label, @@ -231,7 +249,7 @@ fun SidebarItem( maxLines = 1, softWrap = false, style = LocalTextStyle.current.copy( - fontSize = MaterialTheme.typography.labelLarge.fontSize, + fontSize = MaterialTheme.typography.labelMedium.fontSize, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, color = if (selected) { MaterialTheme.colorScheme.onSecondaryContainer @@ -246,8 +264,8 @@ fun SidebarItem( val buttonModifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 4.dp) - val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp) + .padding(horizontal = 7.dp, vertical = 2.dp) + val contentPadding = PaddingValues(horizontal = 5.dp) if (selected) { SecondaryButton( @@ -255,7 +273,7 @@ fun SidebarItem( modifier = buttonModifier, theme = LocalBaseUITheme.current.buttons.secondary .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(8.dp)), + .copyShape(RoundedCornerShape(5.dp)), content = itemContent, ) } else { @@ -264,7 +282,7 @@ fun SidebarItem( modifier = buttonModifier, theme = LocalBaseUITheme.current.buttons.outline .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(8.dp)), + .copyShape(RoundedCornerShape(5.dp)), hoverOnly = true, content = itemContent, ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt new file mode 100644 index 00000000..df99a0eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt @@ -0,0 +1,89 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMirrorScreenRegular: ImageVector + get() { + if (_IconsaxMirrorScreenRegular != null) { + return _IconsaxMirrorScreenRegular!! + } + _IconsaxMirrorScreenRegular = ImageVector.Builder( + name = "IconsaxMirrorScreenRegular", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2f, 9f) + verticalLineTo(8f) + curveTo(2f, 5f, 4f, 3f, 7f, 3f) + horizontalLineTo(17f) + curveTo(20f, 3f, 22f, 5f, 22f, 8f) + verticalLineTo(16f) + curveTo(22f, 19f, 20f, 21f, 17f, 21f) + horizontalLineTo(16f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(3.69f, 11.711f) + curveTo(8.31f, 12.301f, 11.7f, 15.701f, 12.3f, 20.321f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2.62f, 15.07f) + curveTo(6.01f, 15.5f, 8.5f, 18f, 8.94f, 21.39f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(1.98f, 18.859f) + curveTo(3.67f, 19.079f, 4.92f, 20.319f, 5.14f, 22.019f) + } + } + }.build() + + return _IconsaxMirrorScreenRegular!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMirrorScreenRegular: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt index 89e3d481..6bb517c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt @@ -1,20 +1,3 @@ -/* - * 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 . - */ - package dev.krtirtho.spotube.resources.iconsax import androidx.compose.ui.graphics.Color @@ -56,11 +39,7 @@ val Iconsax.IconsaxSound: ImageVector curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f) close() } - path( - fill = SolidColor(Color.White), - fillAlpha = 0.4f, - strokeAlpha = 0.4f - ) { + path(fill = SolidColor(Color.White)) { moveTo(7.5f, 19f) curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f) verticalLineTo(5.75f) @@ -80,11 +59,7 @@ val Iconsax.IconsaxSound: ImageVector curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f) close() } - path( - fill = SolidColor(Color.White), - fillAlpha = 0.4f, - strokeAlpha = 0.4f - ) { + path(fill = SolidColor(Color.White)) { moveTo(16.5f, 19f) curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f) verticalLineTo(5.75f) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSoundTwotone.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSoundTwotone.kt new file mode 100644 index 00000000..4021e59a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSoundTwotone.kt @@ -0,0 +1,97 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSoundTwotone: ImageVector + get() { + if (_IconsaxSoundTwotone != null) { + return _IconsaxSoundTwotone!! + } + _IconsaxSoundTwotone = ImageVector.Builder( + name = "IconsaxSoundTwotone", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(3f, 16.5f) + curveTo(2.59f, 16.5f, 2.25f, 16.16f, 2.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(2.25f, 7.84f, 2.59f, 7.5f, 3f, 7.5f) + curveTo(3.41f, 7.5f, 3.75f, 7.84f, 3.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(7.5f, 19f) + curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(6.75f, 5.34f, 7.09f, 5f, 7.5f, 5f) + curveTo(7.91f, 5f, 8.25f, 5.34f, 8.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(8.25f, 18.66f, 7.91f, 19f, 7.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 21.5f) + curveTo(11.59f, 21.5f, 11.25f, 21.16f, 11.25f, 20.75f) + verticalLineTo(3.25f) + curveTo(11.25f, 2.84f, 11.59f, 2.5f, 12f, 2.5f) + curveTo(12.41f, 2.5f, 12.75f, 2.84f, 12.75f, 3.25f) + verticalLineTo(20.75f) + curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.5f, 19f) + curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(15.75f, 5.34f, 16.09f, 5f, 16.5f, 5f) + curveTo(16.91f, 5f, 17.25f, 5.34f, 17.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(17.25f, 18.66f, 16.91f, 19f, 16.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21f, 16.5f) + curveTo(20.59f, 16.5f, 20.25f, 16.16f, 20.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(20.25f, 7.84f, 20.59f, 7.5f, 21f, 7.5f) + curveTo(21.41f, 7.5f, 21.75f, 7.84f, 21.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(21.75f, 16.16f, 21.41f, 16.5f, 21f, 16.5f) + close() + } + } + }.build() + + return _IconsaxSoundTwotone!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSoundTwotone: ImageVector? = null diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 1d5bfcc0..55147520 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -26,5 +26,5 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac6 toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect -toolchainVendor=ADOPTIUM -toolchainVersion=21 +toolchainVendor=JETBRAINS +toolchainVersion=21 \ No newline at end of file From 2713e63902c30bc9014eb698dcbf991db77fd9ad Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 21:14:34 +0600 Subject: [PATCH 024/132] feat(search): enhance LazyRow with drag scrolling functionality --- .../krtirtho/spotube/modules/search/SearchScreen.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 2b2bb588..bb7fd14b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -39,18 +39,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -84,11 +85,10 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService -import org.koin.compose.koinInject +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField @@ -103,6 +103,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.UserCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel @@ -624,8 +625,10 @@ private fun SearchTabs( selectedType: MetadataSupportedSearchType?, onTabSelected: (MetadataSupportedSearchType) -> Unit, ) { + val listState = rememberLazyListState() LazyRow( - modifier = Modifier.fillMaxWidth(), + state = listState, + modifier = Modifier.fillMaxWidth().dragScrollable(listState), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { From 046c17d3df1d8dfafabf43e29cf621cc3039fc9a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 21:39:08 +0600 Subject: [PATCH 025/132] feat(autocomplete): simplify focus management and remove unused key event handling --- .../core/ui/base/AutocompleteTextField.kt | 51 ++----------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt index 21a73c0f..ca559005 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -70,7 +69,6 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -173,11 +171,9 @@ fun AutocompleteTextField( val isHovered by interactionSource.collectIsHoveredAsState() var isMenuOpen by remember { mutableStateOf(false) } - var isMovingFocusToPopup by remember { mutableStateOf(false) } var selectedIndex by remember { mutableIntStateOf(-1) } var textFieldSize by remember { mutableStateOf(IntSize.Zero) } val menuListState = rememberLazyListState() - val popupFocusRequester = remember { FocusRequester() } val textFieldFocusRequester = remember { FocusRequester() } val menuExpanded = isMenuOpen && items.isNotEmpty() @@ -185,7 +181,6 @@ fun AutocompleteTextField( LaunchedEffect(isFocused) { if (isFocused) { isMenuOpen = true - isMovingFocusToPopup = false } } @@ -291,7 +286,7 @@ fun AutocompleteTextField( ) .focusRequester(textFieldFocusRequester) .onFocusChanged { state -> - if (!state.isFocused && !isMovingFocusToPopup) { + if (!state.isFocused) { isMenuOpen = false selectedIndex = -1 } @@ -303,8 +298,6 @@ fun AutocompleteTextField( if (!menuExpanded) return@onPreviewKeyEvent false when (event.key) { Key.DirectionDown -> { - isMovingFocusToPopup = true - popupFocusRequester.requestFocus() navigate(1) true } @@ -374,7 +367,7 @@ fun AutocompleteTextField( Popup( alignment = Alignment.TopStart, offset = IntOffset(0, textFieldHeightPx + menuOffsetPx), - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = false), onDismissRequest = { selectedIndex = -1 isMenuOpen = false @@ -386,45 +379,7 @@ fun AutocompleteTextField( .heightIn(max = menuMaxHeight) .shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape) .background(resolvedMenuTheme.background, resolvedMenuTheme.shape) - .clip(resolvedMenuTheme.shape) - .focusRequester(popupFocusRequester) - .focusable() - .onFocusChanged { state -> - if (state.isFocused) { - isMovingFocusToPopup = false - } - } - .onKeyEvent { event -> - when (event.key) { - Key.DirectionDown -> { - navigate(1); true - } - - Key.DirectionUp -> { - navigate(-1); true - } - - Key.Enter -> { - if (selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - textFieldFocusRequester.requestFocus() - return@onKeyEvent true - } - false - } - - Key.Escape -> { - selectedIndex = -1 - isMenuOpen = false - textFieldFocusRequester.requestFocus() - true - } - - else -> false - } - }, + .clip(resolvedMenuTheme.shape), ) { LazyColumn( state = menuListState, From 3a85ad08961cc38a4a2e9665f6e3c3b17304161e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 22:39:33 +0600 Subject: [PATCH 026/132] feat(update): implement update checker with dialog for available updates --- composeApp/build.gradle.kts | 64 +++++++-- .../kotlin/dev/krtirtho/spotube/App.kt | 62 +++++++-- .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../modules/settings/SettingsModels.kt | 1 + .../modules/settings/SettingsRepository.kt | 8 +- .../modules/update/UpdateCheckerViewModel.kt | 124 ++++++++++++++++++ .../templates/AppBuildInfo.kt.template | 25 ++++ gradle/libs.versions.toml | 3 + 8 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt create mode 100644 composeApp/src/commonMain/templates/AppBuildInfo.kt.template diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cdca27ed..3d4027e3 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -20,6 +20,7 @@ import gobley.gradle.cargo.dsl.jvm import dev.nucleusframework.desktop.application.dsl.TargetFormat import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.gradle.api.tasks.Copy import java.io.FileInputStream import java.util.Properties @@ -39,11 +40,53 @@ plugins { kotlin("plugin.atomicfu") version libs.versions.kotlin } +val bundleId = "dev.krtirtho.spotube" +val version = "6.0.0" +val buildNumber = 46 +val targetName = "Spotube" +val homePageUrl = "https://spotube.cc" +val repositoryUrl = "https://github.com/team-spotube/spotube" +val appVersionName = version +val appBuildNumber = buildNumber +val appRepositoryUrl = repositoryUrl +val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/") +val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest" +val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin") +val generateAppBuildInfo by tasks.registering(Copy::class) { + description = "Generate AppBuildInfo.kt" + from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template")) + into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/dev/krtirtho/spotube")) + rename("AppBuildInfo.kt.template", "AppBuildInfo.kt") + expand( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) + inputs.properties( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) +} + +tasks.configureEach { + if (name.startsWith("compile") && name.contains("Kotlin")) { + dependsOn(generateAppBuildInfo) + } +} + vlcjBundler { - packageName = "dev.krtirtho.spotube.core.generated" // choose a different package + packageName = "$bundleId.core.generated" // choose a different package objectName = "VLCBundleLoaderGenerated" // or rename the object } + kotlin { // Note: For Android application modules, androidTarget() is still required as of AGP 8.x. // The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead. @@ -68,6 +111,7 @@ kotlin { sourceSets { val commonMain by getting { + kotlin.srcDir(generatedAppInfoDir) dependencies { // Project dependencies implementation(project(":plugin_interfaces")) @@ -172,6 +216,10 @@ kotlin { implementation(libs.mqtt.x.models) implementation(libs.mqtt.buffer) implementation(libs.mqtt.buffer.codec) + + // Markdown renderer + implementation(libs.multiplatform.markdown.renderer) + implementation(libs.multiplatform.markdown.renderer.m3) } } commonTest.dependencies { @@ -249,11 +297,11 @@ android { compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { - applicationId = "dev.krtirtho.spotube" + applicationId = bundleId minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = project.findProperty("versionName") as String? ?: "1.0" + versionCode = buildNumber + versionName = version } packaging { resources { @@ -312,8 +360,8 @@ nucleus.application { ) nativeDistributions { - packageName = "dev.krtirtho.spotube" - packageVersion = project.findProperty("versionName") as String? ?: "6.0.0" + packageName = bundleId + packageVersion = version licenseFile = project.file("../LICENSE") appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) @@ -329,7 +377,7 @@ nucleus.application { modules("jdk.unsupported") - homepage = "https://spotube.cc" + homepage = homePageUrl linux { modules("jdk.security.auth") @@ -371,4 +419,4 @@ cargo { builds.jvm { embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index ae862050..bb6785d2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,49 +22,54 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay +import com.mikepenz.markdown.m3.Markdown import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.shell.AppShell +import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel import dev.krtirtho.spotube.modules.webview.WebViewScreen import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxHome -import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone -import dev.krtirtho.spotube.resources.iconsax.IconsaxSound import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.compose.koinInject import org.koin.compose.navigation3.koinEntryProvider +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI interface NavigationItem { @@ -138,6 +143,8 @@ fun App( ) { val settingsRepository: SettingsRepository = koinInject() val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) + val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel() + val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle() val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES @@ -175,6 +182,43 @@ fun App( } } content() + if (availableUpdate != null) { + val update = availableUpdate!! + ThemedDialog( + onDismissRequest = updateCheckerViewModel::dismissUpdate, + title = { + Text( + text = "Spotube ${update.tag} is available", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) { + Text("Ignore") + } + PrimaryButton( + onClick = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + updateCheckerViewModel.dismissUpdate() + }, + ) { + Text("Update") + } + }, + ) { + if (update.releaseNotesMarkdown.isNotBlank()) { + SelectionContainer { + Markdown( + content = update.releaseNotesMarkdown, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } else { + Text("See what's new in this release on the Spotube downloads page.") + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 4853e271..ca0e7686 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -73,6 +73,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.modules.search.SearchScreenViewModel +import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettingsSource @@ -135,6 +136,7 @@ val sharedModules = module { singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) + viewModelOf(::UpdateCheckerViewModel) // Downloads singleOf(::DownloadManager) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index 3a6792ff..1ff47659 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -87,6 +87,7 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, + val ignoredUpdateVersion: String? = null, ) /** diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt index 8a31bd06..1d6b0d6f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt @@ -59,4 +59,10 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { prefs[SETTINGS_KEY] = Json.encodeToString(newSettings) } } -} \ No newline at end of file + + suspend fun getCurrentSettings(): UserSettings { + val preferences = database.settingsDataStore.data.first() + val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings() + return Json.decodeFromString(settingsJson) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt new file mode 100644 index 00000000..d81692c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt @@ -0,0 +1,124 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.update + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.AppBuildInfo +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.swiftzer.semver.SemVer + +data class AvailableUpdate( + val tag: String, + val releaseNotesMarkdown: String, +) + +class UpdateCheckerViewModel( + private val settingsRepository: SettingsRepository, +) : ViewModel() { + private val logger = Logger.withTag("UpdateChecker") + private val httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + viewModelScope.launch { + checkForUpdate() + } + } + + private suspend fun checkForUpdate() { + try { + // Read directly from DataStore so the check honors persisted preferences, + // rather than the repository StateFlow's default value during startup. + val settings = settingsRepository.getCurrentSettings() + if (!settings.autoCheckForUpdates) return + + val release = httpClient.get(AppBuildInfo.LATEST_RELEASE_API_URL) { + header(HttpHeaders.Accept, "application/vnd.github+json") + header(HttpHeaders.UserAgent, "Spotube/${AppBuildInfo.VERSION}") + }.body() + + val latestVersion = parseVersion(release.tagName.removePrefix("v")) + ?: return logger.w { "Ignoring release with invalid SemVer tag: ${release.tagName}" } + val currentVersion = parseVersion(AppBuildInfo.VERSION) + ?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" } + + if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) { + _availableUpdate.value = AvailableUpdate( + tag = release.tagName, + releaseNotesMarkdown = release.body.orEmpty(), + ) + } + } catch (e: Exception) { + logger.w(e) { "Failed to check for app updates" } + } + } + + fun ignoreUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + viewModelScope.launch { + runCatching { + val settings = settingsRepository.getCurrentSettings() + settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag)) + }.onFailure { e -> + logger.e(e) { "Failed to persist ignored app version" } + } + } + } + + fun dismissUpdate() { + _availableUpdate.value = null + } + + private fun parseVersion(version: String): SemVer? = runCatching { + SemVer.parse(version) + }.getOrNull() + + override fun onCleared() { + httpClient.close() + } +} + +@Serializable +private data class GitHubRelease( + @SerialName("tag_name") val tagName: String, + val body: String? = null, +) diff --git a/composeApp/src/commonMain/templates/AppBuildInfo.kt.template b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template new file mode 100644 index 00000000..b0435644 --- /dev/null +++ b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template @@ -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 . + */ + +package dev.krtirtho.spotube + +internal object AppBuildInfo { + const val VERSION = "${appVersion}" + const val BUILD_NUMBER = ${appBuildNumber} + const val REPOSITORY_URL = "${repositoryUrl}" + const val LATEST_RELEASE_API_URL = "${latestReleaseApiUrl}" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e78d2a0..3eab5800 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ kotlinx-serialization-json = "1.11.0" materialKolor = "4.1.1" mqttClient = "2.1.1" mqttBuffer = "6.30.8" +multiplatformMarkdownRenderer = "0.45.0" murmurhash = "0.4.2" newpipeextractor = "v0.26.5" newpipeExtractorKmp = "1.3.0" @@ -128,6 +129,8 @@ mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClie mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } +multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } +multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } From 4e2afeaea411d848ab57efc7e83b1548c129c41f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 30 Sep 2026 09:58:04 +0600 Subject: [PATCH 027/132] feat(update): refactor update checking mechanism and implement new dialog for app and plugin updates --- AGENTS.md | 1 + .../composeResources/values/strings.xml | 8 + .../kotlin/dev/krtirtho/spotube/App.kt | 75 ++++------ .../dev/krtirtho/spotube/core/di/Modules.kt | 8 +- .../modules/settings/SettingsModels.kt | 3 +- .../spotube/modules/update/AppUpdateDialog.kt | 80 ++++++++++ ...heckerViewModel.kt => AppUpdateService.kt} | 37 +++-- .../modules/update/PluginUpdateDialog.kt | 89 ++++++++++++ .../modules/update/PluginUpdateService.kt | 137 ++++++++++++++++++ gradle/gradle-daemon-jvm.properties | 1 - gradle/libs.versions.toml | 6 +- 11 files changed, 376 insertions(+), 69 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt rename composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/{UpdateCheckerViewModel.kt => AppUpdateService.kt} (78%) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt diff --git a/AGENTS.md b/AGENTS.md index 7c7a90a5..25f345a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. ## UI component patterns +- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen. - **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors. - **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ec223223..dcddd6af 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -198,4 +198,12 @@ Go to album Sleep timer Track details + + Ignore + Update + Spotube %1$s is available + See what\'s new in this release on the Spotube downloads page. + %1$s %2$s is available + Currently installed: %1$s + See what\'s new in this update from the plugin\'s page. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index bb6785d2..6d9c9b22 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,30 +22,20 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay -import com.mikepenz.markdown.m3.Markdown import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme @@ -53,7 +43,10 @@ import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.shell.AppShell -import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel +import dev.krtirtho.spotube.modules.update.AppUpdateDialog +import dev.krtirtho.spotube.modules.update.AppUpdateService +import dev.krtirtho.spotube.modules.update.PluginUpdateDialog +import dev.krtirtho.spotube.modules.update.PluginUpdateService import dev.krtirtho.spotube.modules.webview.WebViewScreen import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd @@ -69,7 +62,6 @@ import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.compose.koinInject import org.koin.compose.navigation3.koinEntryProvider -import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI interface NavigationItem { @@ -143,8 +135,10 @@ fun App( ) { val settingsRepository: SettingsRepository = koinInject() val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) - val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel() - val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle() + val appUpdateService: AppUpdateService = koinInject() + val pluginUpdateService: PluginUpdateService = koinInject() + val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle() + val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle() val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES @@ -182,42 +176,27 @@ fun App( } } content() - if (availableUpdate != null) { - val update = availableUpdate!! - ThemedDialog( - onDismissRequest = updateCheckerViewModel::dismissUpdate, - title = { - Text( - text = "Spotube ${update.tag} is available", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + appUpdateService.dismissUpdate() }, - actions = { - OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) { - Text("Ignore") - } - PrimaryButton( - onClick = { - runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } - updateCheckerViewModel.dismissUpdate() - }, - ) { - Text("Update") - } + ) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser(update.info.directDownloadUrl) } + pluginUpdateService.dismissUpdate() }, - ) { - if (update.releaseNotesMarkdown.isNotBlank()) { - SelectionContainer { - Markdown( - content = update.releaseNotesMarkdown, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - } else { - Text("See what's new in this release on the Spotube downloads page.") - } - } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index ca0e7686..e94d4222 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -73,7 +73,8 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.modules.search.SearchScreenViewModel -import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel +import dev.krtirtho.spotube.modules.update.AppUpdateService +import dev.krtirtho.spotube.modules.update.PluginUpdateService import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettingsSource @@ -136,7 +137,10 @@ val sharedModules = module { singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) - viewModelOf(::UpdateCheckerViewModel) + + // Updates + single { AppUpdateService(get()) } withOptions { createdAtStart() } + single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } // Downloads singleOf(::DownloadManager) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index 1ff47659..55173afc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -87,7 +87,8 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, - val ignoredUpdateVersion: String? = null, + val ignoredAppUpdateVersion: String? = null, + val ignoredPluginUpdateVersions: Map = emptyMap(), ) /** diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt new file mode 100644 index 00000000..b007002d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt @@ -0,0 +1,80 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.update + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.m3.Markdown +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.app_update_dialog_no_notes +import spotube.composeapp.generated.resources.app_update_dialog_title +import spotube.composeapp.generated.resources.update_action_ignore +import spotube.composeapp.generated.resources.update_action_update + +/** + * Prompts the user that a newer Spotube release is available on GitHub. [onUpdate] + * should take the user to the downloads page; [onIgnore] persistently silences this + * specific release tag so the check moves on to the next newer one. + */ +@Composable +fun AppUpdateDialog( + update: AvailableAppUpdate, + onDismiss: () -> Unit, + onIgnore: () -> Unit, + onUpdate: () -> Unit, +) { + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(Res.string.app_update_dialog_title, update.tag), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + OutlineButton(onClick = onIgnore) { + Text(stringResource(Res.string.update_action_ignore)) + } + PrimaryButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + }, + ) { + if (update.releaseNotesMarkdown.isNotBlank()) { + SelectionContainer { + Markdown( + content = update.releaseNotesMarkdown, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } else { + Text(stringResource(Res.string.app_update_dialog_no_notes)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt rename to composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt index d81692c7..19c5a8dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt @@ -17,8 +17,6 @@ package dev.krtirtho.spotube.modules.update -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import dev.krtirtho.spotube.AppBuildInfo import dev.krtirtho.spotube.modules.settings.SettingsRepository @@ -30,6 +28,9 @@ import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,27 +39,35 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import net.swiftzer.semver.SemVer +import org.koin.core.component.KoinComponent -data class AvailableUpdate( +data class AvailableAppUpdate( val tag: String, val releaseNotesMarkdown: String, ) -class UpdateCheckerViewModel( +/** + * Checks GitHub for a newer app release once on startup and exposes it for the UI to + * surface via [dev.krtirtho.spotube.modules.update.AppUpdateDialog]. This is an + * app-scoped singleton (not a ViewModel) since the check runs once for the whole + * process, independent of any screen's lifecycle. + */ +class AppUpdateService( private val settingsRepository: SettingsRepository, -) : ViewModel() { - private val logger = Logger.withTag("UpdateChecker") +) : KoinComponent, AutoCloseable { + private val logger = Logger.withTag("AppUpdateService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val httpClient = HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } - private val _availableUpdate = MutableStateFlow(null) - val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() init { - viewModelScope.launch { + scope.launch { checkForUpdate() } } @@ -80,8 +89,8 @@ class UpdateCheckerViewModel( val currentVersion = parseVersion(AppBuildInfo.VERSION) ?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" } - if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) { - _availableUpdate.value = AvailableUpdate( + if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) { + _availableUpdate.value = AvailableAppUpdate( tag = release.tagName, releaseNotesMarkdown = release.body.orEmpty(), ) @@ -94,10 +103,10 @@ class UpdateCheckerViewModel( fun ignoreUpdate() { val update = _availableUpdate.value ?: return _availableUpdate.value = null - viewModelScope.launch { + scope.launch { runCatching { val settings = settingsRepository.getCurrentSettings() - settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag)) + settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag)) }.onFailure { e -> logger.e(e) { "Failed to persist ignored app version" } } @@ -112,7 +121,7 @@ class UpdateCheckerViewModel( SemVer.parse(version) }.getOrNull() - override fun onCleared() { + override fun close() { httpClient.close() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt new file mode 100644 index 00000000..97641c6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt @@ -0,0 +1,89 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.update + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.m3.Markdown +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_update_dialog_current_version +import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes +import spotube.composeapp.generated.resources.plugin_update_dialog_title +import spotube.composeapp.generated.resources.update_action_ignore +import spotube.composeapp.generated.resources.update_action_update + +/** + * Prompts the user that a newer version of one of their installed plugins is + * available. [onUpdate] should open the plugin's direct download URL; [onIgnore] + * persistently silences this specific plugin version so the check moves on to the + * next newer one (or another plugin). + */ +@Composable +fun PluginUpdateDialog( + update: AvailablePluginUpdate, + onDismiss: () -> Unit, + onIgnore: () -> Unit, + onUpdate: () -> Unit, +) { + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource( + Res.string.plugin_update_dialog_title, + update.pluginName, + update.info.latestVersion, + ), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + OutlineButton(onClick = onIgnore) { + Text(stringResource(Res.string.update_action_ignore)) + } + PrimaryButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + }, + ) { + Text( + text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + if (update.info.releaseNotes.isNotBlank()) { + SelectionContainer { + Markdown(content = update.info.releaseNotes) + } + } else { + Text(stringResource(Res.string.plugin_update_dialog_no_notes)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt new file mode 100644 index 00000000..03f4f53b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt @@ -0,0 +1,137 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.update + +import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import dev.krtirtho.spotube.core.zipline.PluginService +import dev.krtirtho.spotube.modules.plugin.PluginEntry +import dev.krtirtho.spotube.modules.plugin.PluginManager +import dev.krtirtho.spotube.modules.plugin.PluginManagerStates +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import net.swiftzer.semver.SemVer +import org.koin.core.component.KoinComponent + +data class AvailablePluginUpdate( + val pluginId: String, + val pluginName: String, + val currentVersion: String, + val info: PluginUpdateInfo, +) + +/** + * Checks the currently active plugins for updates through their own [PluginService.use] + * -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and + * exposes at most one pending update at a time for the UI to surface via + * [dev.krtirtho.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped + * singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager] + * independent of any screen's lifecycle. + */ +class PluginUpdateService( + private val pluginManager: PluginManager, + private val settingsRepository: SettingsRepository, +) : KoinComponent { + private val logger = Logger.withTag("PluginUpdateService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + scope.launch { + pluginManager.ziplineServices.filterNotNull().collect { services -> + checkForUpdates(services.values.filterNotNull()) + } + } + } + + private suspend fun checkForUpdates(services: List) { + // Only surface one plugin update prompt at a time; re-checks happen the + // next time the selected plugin set changes. + if (_availableUpdate.value != null) return + + val settings = settingsRepository.getCurrentSettings() + if (!settings.autoCheckForUpdates) return + + for (service in services.distinctBy { it.pluginId }) { + val pluginEntry = findPluginEntry(service.pluginId) ?: continue + val currentVersion = parseVersion(pluginEntry.version) ?: continue + + try { + val updateInfo = service.use { coreAPI.checkPluginUpdates(currentVersion) } ?: continue + val latestVersion = parseVersion(updateInfo.latestVersion) + if (latestVersion == null) { + logger.w { "Ignoring plugin update with invalid SemVer: ${updateInfo.latestVersion}" } + continue + } + + val ignoredVersion = settings.ignoredPluginUpdateVersions[pluginEntry.id] + if (latestVersion > currentVersion && updateInfo.latestVersion != ignoredVersion) { + _availableUpdate.value = AvailablePluginUpdate( + pluginId = pluginEntry.id, + pluginName = pluginEntry.name, + currentVersion = pluginEntry.version, + info = updateInfo, + ) + return + } + } catch (e: Exception) { + logger.w(e) { "Failed to check updates for plugin ${pluginEntry.name}" } + } + } + } + + private fun findPluginEntry(pluginId: String): PluginEntry? { + val state = pluginManager.state.value as? PluginManagerStates.Data ?: return null + return state.plugins.find { it.id == pluginId } + } + + fun ignoreUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + val settings = settingsRepository.getCurrentSettings() + settingsRepository.updateSettings( + settings.copy( + ignoredPluginUpdateVersions = settings.ignoredPluginUpdateVersions + + (update.pluginId to update.info.latestVersion) + ) + ) + }.onFailure { e -> + logger.e(e) { "Failed to persist ignored plugin update version" } + } + } + } + + fun dismissUpdate() { + _availableUpdate.value = null + } + + private fun parseVersion(version: String): SemVer? = runCatching { + SemVer.parse(version) + }.getOrNull() +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 55147520..cef9c030 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -26,5 +26,4 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac6 toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect -toolchainVendor=JETBRAINS toolchainVersion=21 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3eab5800..c1505c23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ multiplatformMarkdownRenderer = "0.45.0" murmurhash = "0.4.2" newpipeextractor = "v0.26.5" newpipeExtractorKmp = "1.3.0" -nucleusNucleusApplication = "2.4.6" +nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" @@ -61,8 +61,8 @@ kotlinStdlib = "2.4.10" runner = "1.7.0" core = "1.7.0" filekit = "0.14.2" -compose-webview = "1.0.1" -composeNativeTray = "2.0.3" +compose-webview = "1.0.3" +composeNativeTray = "2.1.6" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" compose-multiplatform-adaptive = "1.3.0-alpha05" From 0def3e2e4909fda4e8bee1e5788cf981ab69a531 Mon Sep 17 00:00:00 2001 From: Henrik Brautaset Aronsen Date: Wed, 30 Sep 2026 10:06:42 +0200 Subject: [PATCH 028/132] Upgrade kmp-zip to 1.0.0 https://github.com/henrik242/kmp-zip/blob/main/CHANGELOG.md --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1505c23..ef7e99a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,7 @@ haze = "2.0.0-alpha03" jna = "5.18.1" junit = "4.13.2" kermit = "2.1.0" -kmpZip = "0.13.0" +kmpZip = "1.0.0" kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" From eeaff3c1dbd4b07e2d78b2e4a62f0596933901cf Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 2 Oct 2026 20:05:23 +0600 Subject: [PATCH 029/132] refactor(main): simplify main function and remove unused WebKit GPU compositing logic --- .../core/webview/PlatformWebviewScreen.kt | 1 - .../kotlin/dev/krtirtho/spotube/main.kt | 45 +------------------ gradle/libs.versions.toml | 2 +- 3 files changed, 2 insertions(+), 46 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index 93ecbd47..8751e1ec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -215,7 +215,6 @@ fun PlatformWebViewScreen(webViewController: WebViewController) { webViewJsBridge = webViewBridge, onCreated = { webView -> navigator.evaluateJavaScript(bridgeBootstrapScript) - }, ) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 171f215b..2a203feb 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -58,52 +58,9 @@ private object KoinServicesProvider : KoinComponent { val settingsProvider: SettingsProvider get() = get() } -private interface LibC : Library { - fun setenv(name: String, value: String, overwrite: Int): Int - - companion object { - val INSTANCE: LibC = Native.load("c", LibC::class.java) - } -} - -/** - * WebKitGTK is only used on Linux (Windows = WebView2, macOS = WKWebView). - * The webview is created *after* the window is already mapped and the Tao - * render/swap loop is running. WebKitGTK's accelerated-compositing path then - * initialises its own GL context in-process, racing Tao's swap thread on the - * same Mesa display, which deterministically segfaults `libgallium` on the - * next Compose flush. Disabling WebKit's hardware-accelerated compositing - * (and its DMABUF renderer) removes that GL context entirely — login pages - * render fine in software. Must run before libwebkit2gtk is loaded. - */ -private fun disableWebKitGpuCompositing() { - if (!System.getProperty("os.name").lowercase().contains("linux")) return - LibC.INSTANCE.setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 1) - LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1) -} - -/** - * Routes `spotube://` deep links into [ExternalUriHandler]. - * macOS delivers them through the open-URI handler; on Linux/Windows they arrive - * as command line arguments (scheme registration is handled by the distribution - * packaging, e.g. the `.desktop` file's `Exec %u`). - */ -private fun handleStartupDeepLinks(args: Array) { - runCatching { - if (java.awt.Desktop.isDesktopSupported()) { - java.awt.Desktop.getDesktop().setOpenURIHandler { event -> - ExternalUriHandler.onNewUri(event.uri.toString()) - } - } - } - args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) } - ?.let(ExternalUriHandler::onNewUri) -} @OptIn(ExperimentalComposeUiApi::class) -fun main(args: Array) { - disableWebKitGpuCompositing() - handleStartupDeepLinks(args) +fun main() { FileKit.init(appId = "dev.krtirtho.spotube") initKoin() NewPipeDownloader.init(KoinPathsProvider.paths) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1505c23..8f1e5295 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,7 +61,7 @@ kotlinStdlib = "2.4.10" runner = "1.7.0" core = "1.7.0" filekit = "0.14.2" -compose-webview = "1.0.3" +compose-webview = "0.1.0-SNAPSHOT" composeNativeTray = "2.1.6" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" From fab60107e33a1604578ee987d2d365b208fdf2e8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 2 Oct 2026 20:57:50 +0600 Subject: [PATCH 030/132] feat(scrobble): implement ScrobbleManager to handle track scrobbling and integrate with audio player --- .../dev/krtirtho/spotube/core/di/Modules.kt | 6 + .../spotube/core/scrobble/ScrobbleManager.kt | 272 ++++++++++++ .../spotube/modules/plugin/PluginManager.kt | 30 +- .../core/audioplayer/FakeAudioPlayer.kt | 27 +- .../core/scrobble/ScrobbleManagerTest.kt | 408 ++++++++++++++++++ 5 files changed, 730 insertions(+), 13 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index e94d4222..d9fd4290 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.core.remote.RemoteControlService import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.core.scrobble.ScrobbleManager import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager @@ -69,6 +70,7 @@ import dev.krtirtho.spotube.modules.plugin.AudioPluginSource import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginViewModel +import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.search.SearchRepository @@ -130,6 +132,7 @@ val sharedModules = module { singleOf(::PluginManager) { bind() bind() + bind() } viewModelOf(::PluginViewModel) @@ -268,6 +271,9 @@ val sharedModules = module { single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } + // Scrobbling + single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() } + factory { (tag: String?) -> if (tag != null) { Logger.withTag(tag) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt new file mode 100644 index 00000000..7e622b32 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt @@ -0,0 +1,272 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.scrobble + +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.time.Clock + +/** + * Watches local playback and submits scrobbles through the currently selected scrobble plugin. + * + * Scrobbling follows the standard Last.fm rules, which plugins are expected to honour as well: + * - Tracks shorter than [MIN_TRACK_DURATION_MS] are never scrobbled. + * - The track must be listened to for at least half of its duration, or [MAX_SCROBBLE_THRESHOLD_MS], + * whichever comes first. + * + * Only time during which the player is actively [PlayerState.PLAYING] is counted, so pauses are + * ignored, and forward seeks don't credit unheard audio. The timestamp sent with the scrobble is + * the moment playback of the track started. Each play is scrobbled at most once; replaying or + * repeating the track starts a new scrobble session. + */ +class ScrobbleManager( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val scrobblePluginSource: ScrobblePluginSource, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) : KoinComponent { + + private val logger by injectLogger() + + private var activeTrackKey: String? = null + private var playbackStartedAtEpochSeconds: Long? = null + private var listenedMs: Long = 0L + private var lastPositionMs: Long? = null + private var scrobbled = false + + init { + scope.launch { + combine( + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.playerStateFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow, + ) { entry, playerState, position, duration -> + PlaybackSnapshot( + entry = entry, + playerState = playerState, + positionMs = position.inWholeMilliseconds, + playerDurationMs = duration.inWholeMilliseconds, + ) + }.collect(::onPlaybackSnapshot) + } + } + + private fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) { + val entry = snapshot.entry + if (entry == null) { + clearSession() + return + } + + val trackKey = entry.trackKey() + if (trackKey != activeTrackKey) { + beginSession(trackKey, snapshot.positionMs) + return + } + + val previousPositionMs = lastPositionMs + lastPositionMs = snapshot.positionMs + + if (snapshot.playerState != PlayerState.PLAYING) return + if (previousPositionMs == null) { + playbackStartedAtEpochSeconds = playbackStartedAtEpochSeconds ?: nowEpochSeconds() + return + } + + if (playbackStartedAtEpochSeconds == null) { + playbackStartedAtEpochSeconds = nowEpochSeconds() + } + + val deltaMs = snapshot.positionMs - previousPositionMs + if (deltaMs < 0) { + // A backward seek to the very beginning means the play started over + // (e.g. repeat one), so allow it to be scrobbled again. + if (snapshot.positionMs <= RESTART_POSITION_THRESHOLD_MS) { + restartSession(snapshot.positionMs) + } + return + } + + // A jump larger than a couple of position ticks is a forward seek or a + // buffering/app-suspension stall. Never credit unplayed audio for it. + if (deltaMs > MAX_COUNTED_POSITION_DELTA_MS) return + + listenedMs += deltaMs + if (scrobbled) return + + val durationMs = entry.durationMs().takeIf { it > 0 } ?: snapshot.playerDurationMs + val thresholdMs = scrobbleThresholdMs(durationMs) ?: return + if (listenedMs < thresholdMs) return + + if (scrobblePluginSource.selectedScrobblePlugin.value == null) { + // No scrobble plugin selected yet: keep the session hot so it can be + // submitted if the user selects one while the track is still playing. + return + } + + scrobbled = true + val track = entry.toScrobbleTrack( + timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), + durationMs = durationMs, + ) + scope.launch { submitScrobble(track) } + } + + private suspend fun submitScrobble(track: ScrobbleTrack) { + val plugin = scrobblePluginSource.selectedScrobblePlugin.value ?: return + try { + plugin.use { scrobbleAPI.scrobble(track) } + logger.d { "Scrobbled \"${track.trackName}\" by ${track.artistName} via ${plugin.pluginId}" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Failed to scrobble \"${track.trackName}\" by ${track.artistName}" } + } + } + + private fun beginSession(trackKey: String, positionMs: Long) { + activeTrackKey = trackKey + restartSession(positionMs) + } + + private fun restartSession(positionMs: Long) { + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = positionMs + scrobbled = false + } + + private fun clearSession() { + activeTrackKey = null + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = null + scrobbled = false + } + + private fun nowEpochSeconds(): Long = Clock.System.now().epochSeconds + + private fun QueueEntry.trackKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.durationMs(): Long = when (this) { + is QueueEntry.StreamingTrack -> track.durationMs + is QueueEntry.LocalTrack -> duration + } + + private fun QueueEntry.toScrobbleTrack(timestamp: Long, durationMs: Long): ScrobbleTrack { + return when (this) { + is QueueEntry.StreamingTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = track.id, + artistId = track.artists.firstOrNull()?.id.orEmpty(), + albumId = track.album?.id, + trackName = track.title, + artistName = track.artists.joinToString(", ") { it.name }, + albumName = track.album?.title, + streamingProvider = resolveStreamingProvider(track), + durationMs = durationMs, + ) + + is QueueEntry.LocalTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = url, + artistId = "", + albumId = null, + trackName = name, + artistName = artists.joinToString(", "), + albumName = album, + streamingProvider = LOCAL_PROVIDER, + durationMs = durationMs, + ) + } + } + + /** Maps a track's external URI to the streaming provider key scrobble plugins expect. */ + private fun resolveStreamingProvider(track: MetadataTrack): String { + val host = track.externalUri + ?.substringAfter("://", missingDelimiterValue = "") + ?.substringBefore('/') + ?.substringAfter('@') + ?.substringBefore(':') + ?.lowercase() + ?.removePrefix("www.") + .orEmpty() + + if (host.isBlank()) return UNKNOWN_PROVIDER + + return when { + "spotify" in host -> "spotify" + "apple" in host -> "apple_music" + "music.youtube" in host -> "youtube_music" + "youtube" in host || "youtu.be" in host -> "youtube" + "deezer" in host -> "deezer" + "tidal" in host -> "tidal" + "soundcloud" in host -> "soundcloud" + "amazon" in host -> "amazon_music" + "pandora" in host -> "pandora" + else -> host.substringBefore('.') + } + } + + private data class PlaybackSnapshot( + val entry: QueueEntry?, + val playerState: PlayerState, + val positionMs: Long, + val playerDurationMs: Long, + ) + + companion object { + /** Tracks shorter than this are never scrobbled (Last.fm rule). */ + private const val MIN_TRACK_DURATION_MS = 30_000L + + /** Listening threshold is capped at four minutes (Last.fm rule). */ + private const val MAX_SCROBBLE_THRESHOLD_MS = 4 * 60 * 1000L + + /** Largest position delta credited as listening, covers position polling jitter. */ + private const val MAX_COUNTED_POSITION_DELTA_MS = 2_000L + + /** Positions at or below this are treated as the start of a fresh play. */ + private const val RESTART_POSITION_THRESHOLD_MS = 5_000L + + private const val UNKNOWN_PROVIDER = "unknown" + private const val LOCAL_PROVIDER = "local" + + private fun scrobbleThresholdMs(durationMs: Long): Long? { + if (durationMs < MIN_TRACK_DURATION_MS) return null + return minOf(durationMs / 2, MAX_SCROBBLE_THRESHOLD_MS) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 4212733b..dfc636e7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -76,11 +76,18 @@ interface AudioPluginSource { val selectedAudioPlugin: StateFlow } +/** Narrow read-only view of the active scrobble plugin, used by consumers that only need + * to submit scrobbles against the currently selected plugin without depending on the full + * [PluginManager] (which also owns plugin installation/lifecycle machinery). */ +interface ScrobblePluginSource { + val selectedScrobblePlugin: StateFlow +} + class PluginManager( val database: Database, val paths: Paths, -) : KoinComponent, PluginProvider, AudioPluginSource { +) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -116,7 +123,6 @@ class PluginManager( val json = p[DatabaseKeys.PLUGINS_STATE_KEY] val defaultSelectedPlugins = mapOf( PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - PluginAbility.SCROBBLE to LRCLIB_BUILT_IN_PLUGIN, ) if (json == null) { PluginManagerStates.Data( @@ -136,11 +142,21 @@ class PluginManager( } else it }.toSet() - res.copy( - plugins = (plugins union BUILT_IN_PLUGINS).toList(), - selectedPlugins = res.selectedPlugins.ifEmpty { - defaultSelectedPlugins + val resolvedPlugins = (plugins union BUILT_IN_PLUGINS).toList() + val resolvedPluginsById = resolvedPlugins.associateBy { it.id } + // Drop selections for plugins that don't actually declare the ability + // (e.g. a scrobble selection left over from a removed built-in plugin) + // and always point selections at the resolved plugin entry. + val selectedPlugins = res.selectedPlugins.ifEmpty { defaultSelectedPlugins } + .mapNotNull { (ability, selected) -> + val plugin = resolvedPluginsById[selected.id] ?: return@mapNotNull null + if (ability in plugin.abilities) ability to plugin else null } + .toMap() + + res.copy( + plugins = resolvedPlugins, + selectedPlugins = selectedPlugins, ) } catch (_: Exception) { PluginManagerStates.Data( @@ -256,7 +272,7 @@ class PluginManager( filterSelectedPluginByType(PluginAbility.AUDIO) val selectedLyricsPlugin = filterSelectedPluginByType(PluginAbility.LYRICS) - val selectedScrobblePlugin = + override val selectedScrobblePlugin = filterSelectedPluginByType(PluginAbility.SCROBBLE) diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt index 406134b9..ebfbc450 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt @@ -9,10 +9,13 @@ import kotlin.time.Duration class FakeAudioPlayer : AudioPlayerInterface { private val _playlistFlow = MutableStateFlow>(emptyList()) override val playlistFlow: StateFlow> = _playlistFlow.asStateFlow() - override val durationFlow: StateFlow - get() = TODO("Not yet implemented") - override val positionFlow: StateFlow - get() = TODO("Not yet implemented") + + private val _durationFlow = MutableStateFlow(Duration.ZERO) + override val durationFlow: StateFlow = _durationFlow.asStateFlow() + + private val _positionFlow = MutableStateFlow(Duration.ZERO) + override val positionFlow: StateFlow = _positionFlow.asStateFlow() + override val bufferingPositionFlow: StateFlow get() = TODO("Not yet implemented") @@ -152,8 +155,8 @@ class FakeAudioPlayer : AudioPlayerInterface { _currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index) } - override val playerStateFlow: StateFlow - get() = TODO("Not yet implemented") + private val _playerStateFlow = MutableStateFlow(PlayerState.IDLE) + override val playerStateFlow: StateFlow = _playerStateFlow.asStateFlow() fun setPlaylist(items: List) { _playlistFlow.value = items @@ -167,6 +170,18 @@ class FakeAudioPlayer : AudioPlayerInterface { _loopStateFlow.value = state } + fun setPlayerState(state: PlayerState) { + _playerStateFlow.value = state + } + + fun setPosition(position: Duration) { + _positionFlow.value = position + } + + fun setDuration(duration: Duration) { + _durationFlow.value = duration + } + fun resetCallCounts() { loadCallCount = 0 playCallCount = 0 diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt new file mode 100644 index 00000000..8b0043e9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt @@ -0,0 +1,408 @@ +package dev.krtirtho.spotube.core.scrobble + +import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.FakeAudioPlayer +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.zipline.PluginService +import dev.krtirtho.spotube.core.zipline.PluginServiceScope +import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ScrobbleManagerTest { + + private lateinit var player: FakeAudioPlayer + private lateinit var queue: FakeQueue + private lateinit var plugin: FakeScrobblePluginService + private lateinit var pluginSource: FakeScrobblePluginSource + + /** Virtual playback position advanced by [playSeconds]; mirrors a real player clock. */ + private var currentPositionMs = 0L + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + player = FakeAudioPlayer() + queue = FakeQueue() + plugin = FakeScrobblePluginService() + pluginSource = FakeScrobblePluginSource(plugin) + currentPositionMs = 0L + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun TestScope.createManager(): ScrobbleManager { + return ScrobbleManager( + audioPlayer = player, + audioPlayerQueue = queue, + scrobblePluginSource = pluginSource, + scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)), + ) + } + + private fun playSeconds(seconds: Int) { + repeat(seconds) { + currentPositionMs += 1_000 + player.setPosition(currentPositionMs.milliseconds) + } + } + + @Test + fun `scrobbles once half of the track has been played`() = runTest { + createManager() + queue.setCurrentEntry( + streamingEntry( + id = "t1", + durationMs = 200_000, + externalUri = "https://open.spotify.com/track/t1", + ) + ) + player.setPlayerState(PlayerState.PLAYING) + player.setDuration(200.seconds) + + playSeconds(99) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + + val scrobble = plugin.scrobbled.single() + assertEquals("t1", scrobble.trackId) + assertEquals("artist-t1", scrobble.artistId) + assertEquals("album-t1", scrobble.albumId) + assertEquals("Track t1", scrobble.trackName) + assertEquals("Artist t1", scrobble.artistName) + assertEquals("Album t1", scrobble.albumName) + assertEquals("spotify", scrobble.streamingProvider) + assertEquals(200_000L, scrobble.durationMs) + assertTrue(scrobble.timestamp > 0) + } + + @Test + fun `does not scrobble tracks shorter than 30 seconds`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "short", durationMs = 20_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals(0, plugin.scrobbled.size) + } + + @Test + fun `caps the listening threshold at four minutes`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "long", durationMs = 1_800_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(239) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `only scrobbles a track once per play`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + playSeconds(15) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit forward seeks`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(10) + currentPositionMs = 60_000 + player.setPosition(currentPositionMs.milliseconds) + + playSeconds(39) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit time while paused`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(30) + + player.setPlayerState(PlayerState.PAUSED) + currentPositionMs = 80_000 + player.setPosition(currentPositionMs.milliseconds) + + player.setPlayerState(PlayerState.PLAYING) + playSeconds(19) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `restarting the track allows scrobbling it again`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + // Repeat-one restarts playback from the beginning without a queue entry change. + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + } + + @Test + fun `switching tracks starts a fresh scrobble session`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + queue.setCurrentEntry(streamingEntry(id = "t2", durationMs = 40_000)) + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + assertEquals("t2", plugin.scrobbled.last().trackId) + } + + @Test + fun `waits for a scrobble plugin to be selected`() = runTest { + pluginSource.setPlugin(null) + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(25) + assertEquals(0, plugin.scrobbled.size) + + pluginSource.setPlugin(plugin) + playSeconds(1) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `scrobbles local tracks`() = runTest { + createManager() + queue.setCurrentEntry( + localEntry( + name = "Local Song", + url = "file:///music/local.mp3", + duration = 100_000, + ) + ) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(50) + + val scrobble = plugin.scrobbled.single() + assertEquals("file:///music/local.mp3", scrobble.trackId) + assertEquals("Local Song", scrobble.trackName) + assertEquals("Local Artist", scrobble.artistName) + assertEquals("Local Album", scrobble.albumName) + assertEquals("local", scrobble.streamingProvider) + assertEquals(100_000L, scrobble.durationMs) + } + + @Test + fun `uses unknown provider when the track has no external uri`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000, externalUri = null)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals("unknown", plugin.scrobbled.single().streamingProvider) + } + + // --- fixtures --- + + private fun streamingTrack( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null, + ): MetadataTrack { + val artist = MetadataArtist.Basic( + id = "artist-$id", + name = "Artist $id", + thumbnails = emptyList(), + externalUri = null, + ) + val album = MetadataAlbum.Detailed( + releaseDate = "2024", + genres = emptyList(), + trackCount = 1, + id = "album-$id", + title = "Album $id", + description = null, + thumbnails = emptyList(), + albumType = MetadataAlbumType.Album, + artists = listOf(artist), + externalUri = null, + ) + return MetadataTrack( + id = id, + title = "Track $id", + durationMs = durationMs, + trackNumber = 1, + discNumber = 1, + artists = listOf(artist), + album = album, + thumbnails = null, + explicit = false, + popularity = null, + isrcCode = null, + externalUri = externalUri, + ) + } + + private fun streamingEntry( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null, + ): QueueEntry.StreamingTrack { + return QueueEntry.StreamingTrack( + track = streamingTrack(id, durationMs, externalUri), + url = "http://127.0.0.1:14769/stream/$id", + ) + } + + private fun localEntry( + name: String = "Local Track", + url: String = "file:///music/local.mp3", + duration: Long = 180_000, + ): QueueEntry.LocalTrack { + return QueueEntry.LocalTrack( + name = name, + artists = listOf("Local Artist"), + duration = duration, + album = "Local Album", + coverBytes = null, + url = url, + ) + } + + private class FakeQueue : AudioPlayerQueue { + private val queueState = MutableStateFlow>(emptyList()) + private val currentEntryState = MutableStateFlow(null) + + override val queueFlow: StateFlow> = queueState + override val currentQueueEntryFlow: StateFlow = currentEntryState + override val currentCollectionEntryFlow: StateFlow = + MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = + MutableStateFlow(emptyList()) + + fun setCurrentEntry(entry: QueueEntry?) { + currentEntryState.value = entry + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?, + ) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue( + entries: List, + collectionEntry: QueueCollectionEntry?, + ) = error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = error("not used") + override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used") + override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used") + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used") + override suspend fun reloadCurrent() = error("not used") + override suspend fun clear() = error("not used") + override suspend fun getQueue(): List = queueState.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = currentEntryState.value + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() + } + + private class FakeScrobblePluginSource( + initialPlugin: PluginService? = null, + ) : ScrobblePluginSource { + private val selected = MutableStateFlow(initialPlugin) + override val selectedScrobblePlugin: StateFlow = selected + + fun setPlugin(service: PluginService?) { + selected.value = service + } + } + + private class FakeScrobblePluginService : PluginService { + val scrobbled = mutableListOf() + override val pluginId: String = "fake-scrobble-plugin" + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val api = object : ScrobbleAPI { + override suspend fun scrobble(track: ScrobbleTrack) { + scrobbled.add(track) + } + } + val scope = PluginServiceScope(mapOf(ScrobbleAPI::class to api)) + return scope.block() + } + } +} From 71d7649db62431ea5ab9678f23873f99deb6b167 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 6 Oct 2026 15:29:27 +0600 Subject: [PATCH 031/132] feat(welcome): implement onboarding flow with multiple pages and donation options --- .../drawable/spotube_logo.png | Bin 0 -> 147637 bytes .../composeResources/values/strings.xml | 94 ++++- .../kotlin/dev/krtirtho/spotube/App.kt | 15 +- .../dev/krtirtho/spotube/core/DebugConfig.kt | 33 ++ .../dev/krtirtho/spotube/core/db/Database.kt | 28 ++ .../dev/krtirtho/spotube/core/di/Modules.kt | 6 + .../core/navigation/NavigationModule.kt | 7 + .../core/navigation/NavigationState.kt | 7 +- .../core/ui/component/ApplicationMainBar.kt | 3 +- .../spotube/modules/plugin/PluginScreen.kt | 362 +++++++++++------- .../spotube/modules/plugin/PluginViewModel.kt | 15 +- .../spotube/modules/shell/AppShell.kt | 7 + .../spotube/modules/welcome/WelcomeContent.kt | 66 ++++ .../spotube/modules/welcome/WelcomeModels.kt | 39 ++ .../modules/welcome/WelcomeRepository.kt | 112 ++++++ .../spotube/modules/welcome/WelcomeScreen.kt | 245 ++++++++++++ .../modules/welcome/WelcomeViewModel.kt | 87 +++++ .../spotube/modules/welcome/WelcomeVisuals.kt | 91 +++++ .../modules/welcome/pages/BrandingPage.kt | 46 +++ .../modules/welcome/pages/DonationPage.kt | 70 ++++ .../modules/welcome/pages/HowItWorksPage.kt | 181 +++++++++ .../modules/welcome/pages/PluginsPage.kt | 10 + .../resources/iconsax/BrandBuyMeACoffee.kt | 264 +++++++++++++ .../resources/iconsax/BrandOpencollective.kt | 58 +++ .../spotube/resources/iconsax/BrandPatreon.kt | 54 +++ .../ui/component/ApplicationMainBar.jvm.kt | 11 + .../ui/component/ApplicationMainBar.mobile.kt | 13 +- 27 files changed, 1784 insertions(+), 140 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/spotube_logo.png create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt diff --git a/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png b/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d46d9ce5cc29c5d4f69966c30cc9d26adcda3df3 GIT binary patch literal 147637 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�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%0Ax1Jqtd^98kT7;JTY#Gut(Knvd^JXpj=@cQx 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index dcddd6af..b636db4f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -202,8 +202,98 @@ Ignore Update Spotube %1$s is available - See what\'s new in this release on the Spotube downloads page. + See what’s new in this release on the Spotube downloads page. %1$s %2$s is available Currently installed: %1$s - See what\'s new in this update from the plugin\'s page. + See what’s new in this update from the plugin’s page. + + Skip + Next + Back + Get Started + Spotube + Music, without the strings. + An open-source player for a world of music.\nYours to explore. Yours to make your own. + Keep music free. + Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software. + Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here. + Discover plugins + Your sources + Try again + More plugins + No new plugins found. You can also install one from a link or a .smplug file above. + Plugin discovery is unavailable right now. Try again, or install from a link or file. + A familiar player. A different kind of freedom. + Browse through your chosen catalog. Press play and your audio plugin finds the stream.\n\nYour device handles playback and caching. Add synced lyrics, save tracks for later, or listen together with Group Jam. + DEMONSTRATION + PAUSED + Pause demonstration + Play demonstration + + + Your music. Your sources. Your rules. + A free, open-source music player built by a community that simply loves music. + Open source + No ads + No telemetry + Plugin powered + Let’s Go + + + Supporters keep us going + Built with passion. Kept alive by you. + Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing. + Patreon + Monthly Sponsor + Support recurring development and infrastructure + Buy Me a Coffee + One-Time Coffee + Fuel a late-night coding session with a quick tip + Open Collective + Community Fund + Transparent, community-governed financial backing + Thank you from the bottom of our hearts for believing in open music. + Supporting is completely optional — Spotube stays free either way. + Sponsor + Open %1$s + + + Modular Architecture + Choose where your music comes from. + Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime. + Metadata Engine + Powers music discovery, search, track listings, and playlists. + Audio Engine + Fetches and decodes the audio streams that play. + Lyrics Engine + Streams time-synced karaoke lyrics in real time. + Scrobble Engine + Records listening history to Last.fm or ListenBrainz. + Active + Built-in + Ready + Optional + Select an engine + Installed Engines + Set as default + NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box. + Manage or add new community plugins anytime in Settings → Plugins. + + + Retro-Modern Architecture + Pure Hi-Fi, zero middlemen + Here’s how Spotube orchestrates decentralized playback directly on your machine. + DIRECT PEER-TO-PEER • 256 KBPS OPUS + Blue hour + Spotube · player demonstration + A little music for the moments in between. + 1. Decentralized Discovery + Queries go straight to your chosen catalog plugin. Spotube never profiles your taste. + 2. Direct Stream Resolution + Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting. + 3. Local DSP & Playback + Decoded and cached locally with equalizer controls and optional offline download. + 4. Synced Lyrics & Jam + Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam. + No middleman servers. Playback happens right on your device — private by design. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 6d9c9b22..6eb9bb8c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -48,6 +48,7 @@ import dev.krtirtho.spotube.modules.update.AppUpdateService import dev.krtirtho.spotube.modules.update.PluginUpdateDialog import dev.krtirtho.spotube.modules.update.PluginUpdateService import dev.krtirtho.spotube.modules.webview.WebViewScreen +import dev.krtirtho.spotube.modules.welcome.WelcomeRepository import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive @@ -140,10 +141,17 @@ fun App( val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle() val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle() + val welcomeRepository: WelcomeRepository = koinInject() + val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle() + + // The app always renders the shell + NavDisplay; onboarding is just the start destination. + // When it is completed the destination flips to Home and the navigation state is rebuilt. + val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home + val navigationState = rememberNavigationState( - startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES + startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES ) - val navigator = remember { + val navigator = remember(navigationState) { Navigator(navigationState) } @@ -151,7 +159,8 @@ fun App( val baseUITheme = rememberBaseUITheme() CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { Box(modifier = Modifier.fillMaxSize()) { - val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last() + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() Box(Modifier.fillMaxSize()) { AppShell(navigator, navigationState) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt new file mode 100644 index 00000000..249315bf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt @@ -0,0 +1,33 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core + +/** + * Development-only switches. These are intentionally kept out of [dev.krtirtho.spotube.modules.settings.UserSettings] + * so they can never leak into user-facing configuration. + */ +object DebugConfig { + /** + * Flip to `true` while developing to always open the Welcome/Onboarding screen, even after + * it has been completed (so you don't have to wipe app data to see it again). It still + * dismisses for the session when you finish or skip it. + * + * Keep it `false` when committing. + */ + const val FORCE_SHOW_ONBOARDING: Boolean = true +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt index f268d57f..573efaa1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt @@ -23,10 +23,25 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import dev.krtirtho.spotube.core.paths.Paths +import okio.FileSystem import okio.Path.Companion.toPath +import okio.SYSTEM @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") class Database(val paths: Paths) { + /** + * True when Spotube had already written its own data before this process started. Captured + * synchronously at construction time — the earliest point, before any eager service can + * create a fresh store — so it reliably tells a brand-new install apart from an upgrade of + * an existing one (used to skip the first-run onboarding on upgrades). + */ + val isExistingInstall: Boolean = runCatching { + val dataDirPath = paths.getApplicationDataDirPath() + EXISTING_INSTALL_PROBE_FILES.any { fileName -> + FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath()) + } + }.getOrDefault(false) + val settingsDataStore: DataStore by lazy { PreferenceDataStoreFactory.createWithPath( produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() } @@ -61,6 +76,19 @@ class Database(val paths: Paths) { produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() } ) } + + private companion object { + /** Preference files written by previous launches; their presence means an upgrade. */ + val EXISTING_INSTALL_PROBE_FILES = listOf( + "spotube.preferences_pb", + "spotube_plugins.preferences_pb", + "spotube_matched_tracks.preferences_pb", + "spotube_audio_player_queue.preferences_pb", + "spotube_local_media.preferences_pb", + "spotube_blacklist.preferences_pb", + "spotube_cookies.preferences_pb", + ) + } } object DatabaseKeys { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index d9fd4290..bdf28574 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -86,6 +86,8 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel +import dev.krtirtho.spotube.modules.welcome.WelcomeRepository +import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.createdAtStart @@ -141,6 +143,10 @@ val sharedModules = module { viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) + // Welcome / Onboarding + singleOf(::WelcomeRepository) + viewModelOf(::WelcomeViewModel) + // Updates single { AppUpdateService(get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index b3f7f48f..f1adc2f0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -34,6 +34,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen import dev.krtirtho.spotube.modules.settings.JamSettingsScreen +import dev.krtirtho.spotube.modules.welcome.WelcomeScreen import kotlinx.serialization.Serializable import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI @@ -91,6 +92,9 @@ sealed interface Routes : NavKey { @Serializable data object JamSettings : Routes + + @Serializable + data object Welcome : Routes } @OptIn(KoinExperimentalAPI::class) @@ -178,4 +182,7 @@ val navigationModule = module { navigation { JamSettingsScreen(viewModel = koinViewModel()) } + navigation { + WelcomeScreen(viewModel = koinViewModel()) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt index 3f0ced21..8845db6b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -45,7 +44,9 @@ val TOP_LEVEL_ROUTES = setOf( Routes.Home, Routes.Search, Routes.Library, - Routes.Settings + Routes.Settings, + // Bootstrap destination: needs its own back stack when the app starts on it. + Routes.Welcome, ) val serializersConfig = SavedStateConfiguration { @@ -63,6 +64,7 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) subclass(Routes.Devices::class, Routes.Devices.serializer()) subclass(Routes.Jam::class, Routes.Jam.serializer()) + subclass(Routes.Welcome::class, Routes.Welcome.serializer()) } } } @@ -122,7 +124,6 @@ fun NavigationState.toEntries( val decoratedEntries = backStacks.mapValues { (_, stack) -> val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() ) rememberDecoratedNavEntries( backStack = stack, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt index 359ee469..30cfa7b7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt @@ -47,5 +47,6 @@ expect fun ApplicationMainBar( title: @Composable () -> Unit = {}, subtitle: @Composable () -> Unit = {}, actions: @Composable (RowScope.() -> Unit) = {}, - backButton: Boolean = true + backButton: Boolean = true, + transparent: Boolean = false, ) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index 031397ed..ede1fb60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -102,6 +102,14 @@ import okio.Path import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_plugins_title +import spotube.composeapp.generated.resources.welcome_plugins_setup_guide +import spotube.composeapp.generated.resources.welcome_plugins_discover +import spotube.composeapp.generated.resources.welcome_plugins_retry +import spotube.composeapp.generated.resources.welcome_plugins_more +import spotube.composeapp.generated.resources.welcome_plugins_discovery_empty +import spotube.composeapp.generated.resources.welcome_plugins_discovery_error +import spotube.composeapp.generated.resources.welcome_plugins_sources import spotube.composeapp.generated.resources.plugin_action_download import spotube.composeapp.generated.resources.plugin_action_install_from_file import spotube.composeapp.generated.resources.plugin_configure_title @@ -138,6 +146,7 @@ private val VERIFIED_PLUGIN_OWNERS = setOf() @Composable fun PluginScreen( viewModel: PluginViewModel = koinViewModel(), + onboarding: Boolean = false, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current @@ -215,8 +224,11 @@ fun PluginScreen( Scaffold( topBar = { - ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) - } + if (!onboarding) { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } + }, + containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface, ) { innerPadding -> when (val data = uiState) { is PluginUiState.Loading -> { @@ -244,114 +256,75 @@ fun PluginScreen( ), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(Res.string.plugin_configure_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - PrimaryButton(onClick = { viewModel.showInstallSheet() }) { - Icon( - Iconsax.IconsaxAdd, - contentDescription = "Install a plugin", + if (onboarding) { + item { + Column( + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(Res.string.welcome_plugins_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, ) - Text(stringResource(Res.string.plugin_install_section_title)) + Text( + stringResource(Res.string.welcome_plugins_setup_guide), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + item { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 12.dp) + ) { + OutlineButton(onClick = { viewModel.showOnboardingDiscovery(true) }) { + Text( + stringResource(Res.string.welcome_plugins_discover), + fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal + ) + } + OutlineButton(onClick = { + viewModel.showOnboardingDiscovery( + false + ) + }) { + Text( + stringResource(Res.string.welcome_plugins_sources), + fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal + ) + } } } } - - item { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Column( + if (!onboarding || !data.onboardingDiscover) { + item { + Row( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp, bottom = 4.dp) + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - data.abilitySelections.forEachIndexed { index, selection -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) - } - DefaultAbilityPluginSelector( - selection = selection, - onSelected = { plugin -> - viewModel.selectPlugin(selection.ability, plugin) - }, + Text( + stringResource(Res.string.plugin_configure_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + PrimaryButton(onClick = { viewModel.showInstallSheet() }) { + Icon( + Iconsax.IconsaxAdd, + contentDescription = stringResource(Res.string.plugin_install_section_title), ) + Text(stringResource(Res.string.plugin_install_section_title)) } } } - } - if (data.plugins.isEmpty()) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.size(72.dp) - .clip(RoundedCornerShape(18.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - Text( - stringResource(Res.string.plugin_empty_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - Text( - stringResource(Res.string.plugin_empty_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - item { - val noun = if (data.plugins.size == 1) { - stringResource(Res.string.plugin_installed_singular) - } else { - stringResource(Res.string.plugin_installed_plural) - } - Text( - stringResource( - Res.string.plugin_installed_count, - data.plugins.size, - noun - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - ) - } item { Card( modifier = Modifier @@ -359,9 +332,11 @@ fun PluginScreen( .padding(vertical = 4.dp), ) { Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 4.dp) ) { - data.plugins.forEachIndexed { index, item -> + data.abilitySelections.forEachIndexed { index, selection -> if (index > 0) { HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy( @@ -369,30 +344,128 @@ fun PluginScreen( ), ) } - PluginCard( - plugin = item.plugin, - isSelected = item.isSelected, - onRemove = { viewModel.removePlugin(item.plugin) }, - isLoggedIn = item.authState.isLoggedIn, - logoPath = item.logoPath, - onInfo = { viewModel.showPluginInfo(item.plugin) }, - onSupport = if (item.isSelected) { - { viewModel.loadSupport(item.plugin) } - } else null, - onLogin = if (item.isSelected && item.authState.requiresAuth) { - { viewModel.login(item.plugin) } - } else null, - onLogout = if (item.isSelected && item.authState.requiresAuth) { - { viewModel.logout(item.plugin) } - } else null, + DefaultAbilityPluginSelector( + selection = selection, + onSelected = { plugin -> + viewModel.selectPlugin( + selection.ability, + plugin + ) + }, ) } } } } - } - if (data.discover.isLoading || data.discover.repos.isNotEmpty()) { + if (data.plugins.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + modifier = Modifier.size(72.dp) + .clip(RoundedCornerShape(18.dp)), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + Text( + stringResource(Res.string.plugin_empty_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource(Res.string.plugin_empty_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } else { + item { + val noun = if (data.plugins.size == 1) { + stringResource(Res.string.plugin_installed_singular) + } else { + stringResource(Res.string.plugin_installed_plural) + } + Text( + stringResource( + Res.string.plugin_installed_count, + data.plugins.size, + noun + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 4.dp + ) + ) + } + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + data.plugins.forEachIndexed { index, item -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = 0.5f + ), + ) + } + PluginCard( + plugin = item.plugin, + isSelected = item.isSelected, + onRemove = { viewModel.removePlugin(item.plugin) }, + isLoggedIn = item.authState.isLoggedIn, + logoPath = item.logoPath, + onInfo = { viewModel.showPluginInfo(item.plugin) }, + onSupport = if (item.isSelected) { + { viewModel.loadSupport(item.plugin) } + } else null, + onLogin = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.login(item.plugin) } + } else null, + onLogout = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.logout(item.plugin) } + } else null, + ) + } + } + } + } + } + + } + if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) { + if (onboarding) { + item { + OutlineButton(onClick = viewModel::showInstallSheet) { + Text(stringResource(Res.string.plugin_install_section_title)) + } + } + } item { Row( modifier = Modifier @@ -408,12 +481,31 @@ fun PluginScreen( tint = MaterialTheme.colorScheme.primary ) Text( - "Discover Plugins", + stringResource(Res.string.welcome_plugins_discover), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) } } + if (data.discover.isLoading) { + item { + Box( + Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(Modifier.size(24.dp)) + } + } + } + if (onboarding && data.discover.isInitialLoaded && !data.discover.isLoading && data.discover.repos.isEmpty() && data.discover.error == null) { + item { + Text( + stringResource(Res.string.welcome_plugins_discovery_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp) + ) + } + } items( data.discover.repos, key = { it.id } @@ -440,15 +532,27 @@ fun PluginScreen( data.discover.error?.let { error -> item { - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding( - horizontal = 4.dp, - vertical = 8.dp + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_plugins_discovery_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 8.dp + ) ) - ) + OutlineButton(onClick = viewModel::retryDiscovery) { + Text(stringResource(Res.string.welcome_plugins_retry)) + } + } + } + } + if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) { + item { + OutlineButton(onClick = viewModel::loadNextDiscoverPage) { + Text(stringResource(Res.string.welcome_plugins_more)) + } } } } @@ -468,8 +572,8 @@ fun PluginScreen( // Pagination trigger stays in the composable because it is driven by // LazyList layout information, which only exists in UI scope. // The actual loading call is forwarded to the ViewModel. - LaunchedEffect(shouldLoadMore.value) { - if (shouldLoadMore.value) { + LaunchedEffect(shouldLoadMore.value, onboarding) { + if (!onboarding && shouldLoadMore.value) { viewModel.loadNextDiscoverPage() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt index 004580f9..2f8917bb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt @@ -87,6 +87,7 @@ sealed interface PluginUiState { data object Loading : PluginUiState data class Data( + val onboardingDiscover: Boolean = true, val plugins: List, val abilitySelections: List, val pendingPlugin: PluginManager.PendingPlugin? = null, @@ -118,6 +119,7 @@ private data class PluginManagerSnapshot( ) private data class PluginScreenState( + val onboardingDiscover: Boolean = true, val urlInput: String = "", val urlError: UrlError? = null, val isLoadingUrl: Boolean = false, @@ -239,6 +241,7 @@ class PluginViewModel( when (val state = snapshot.state) { is PluginManagerStates.Loading -> PluginUiState.Loading is PluginManagerStates.Data -> PluginUiState.Data( + onboardingDiscover = screen.onboardingDiscover, plugins = state.plugins.map { plugin -> PluginListItem( plugin = plugin, @@ -468,6 +471,10 @@ class PluginViewModel( pluginManager.setSelectedPlugin(ability, plugin) } + fun showOnboardingDiscovery(show: Boolean) { + _screenState.update { it.copy(onboardingDiscover = show) } + } + fun removePlugin(plugin: PluginEntry) { viewModelScope.launch { runCatching { @@ -512,6 +519,11 @@ class PluginViewModel( return repos.filter { it.htmlUrl !in installedUrls } } + fun retryDiscovery() { + if (_screenState.value.discover.isLoading) return + loadFirstDiscoverPage() + } + private fun loadFirstDiscoverPage() { viewModelScope.launch { _screenState.update { @@ -590,7 +602,8 @@ class PluginViewModel( } fun installPlugin(release: GitHubRelease, repoId: Long) { - val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl + val smplugUrl = + release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl if (smplugUrl != null) { installPluginFromUrl(smplugUrl, repoId) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index b044e3a4..4d64ed63 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -129,6 +129,13 @@ fun AppShell( } } + // Onboarding is a bootstrap, full-screen destination. It draws its own app bar (and thus + // the desktop window controls), so render it without the sidebar, players or bottom bar. + if (navigationState.topLevelRoute == Routes.Welcome) { + content() + return + } + ConnectionRequestDialogHost() PlayDestinationPickerHost() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt new file mode 100644 index 00000000..b5ea2ede --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt @@ -0,0 +1,66 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.welcome + +import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee +import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective +import dev.krtirtho.spotube.resources.iconsax.BrandPatreon +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle +import spotube.composeapp.generated.resources.welcome_donate_bmac_title +import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle +import spotube.composeapp.generated.resources.welcome_donate_opencollective_title +import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle +import spotube.composeapp.generated.resources.welcome_donate_patreon_title +import spotube.composeapp.generated.resources.welcome_get_started +import spotube.composeapp.generated.resources.welcome_lets_go +import spotube.composeapp.generated.resources.welcome_next + +/** Order matches the 4 onboarding pages. */ +internal val welcomePrimaryLabels: List = listOf( + Res.string.welcome_lets_go, + Res.string.welcome_next, + Res.string.welcome_next, + Res.string.welcome_get_started, +) + +internal val welcomeDonations: List = listOf( + DonationOption( + id = "patreon", + title = Res.string.welcome_donate_patreon_title, + subtitle = Res.string.welcome_donate_patreon_subtitle, + url = "https://patreon.com/krtirtho", + icon = Iconsax.BrandPatreon, + ), + DonationOption( + id = "buymeacoffee", + title = Res.string.welcome_donate_bmac_title, + subtitle = Res.string.welcome_donate_bmac_subtitle, + url = "https://www.buymeacoffee.com/krtirtho", + icon = Iconsax.BrandBuyMeACoffee, + ), + DonationOption( + id = "opencollective", + title = Res.string.welcome_donate_opencollective_title, + subtitle = Res.string.welcome_donate_opencollective_subtitle, + url = "https://opencollective.com/spotube", + icon = Iconsax.BrandOpencollective, + ), +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt new file mode 100644 index 00000000..b8199ef9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt @@ -0,0 +1,39 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.welcome + +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.StringResource + +/** A single donation destination shown on the support page. */ +data class DonationOption( + val id: String, + val title: StringResource, + val subtitle: StringResource, + val url: String, + val icon: ImageVector, +) + +data class WelcomeUiState( + val currentPageIndex: Int, + val pageCount: Int, + val isLastPage: Boolean, + val primaryLabel: StringResource, + val donations: List, + val demoPlaying: Boolean = true, +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt new file mode 100644 index 00000000..6240e22d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt @@ -0,0 +1,112 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.welcome + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dev.krtirtho.spotube.core.DebugConfig +import dev.krtirtho.spotube.core.db.Database +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Owns whether the Welcome/Onboarding flow should be the app's start destination. + * + * This lives in its own preference rather than [dev.krtirtho.spotube.modules.settings.UserSettings] + * so the very first launch can be distinguished from "settings not read yet". It is a Koin + * singleton so the app-level start route and the routed [WelcomeScreen] observe the same state. + */ +class WelcomeRepository(private val database: Database) { + companion object { + private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed") + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Set when onboarding is finished for this process only; does not persist (debug forcing). */ + private val sessionDismissed = MutableStateFlow(false) + + /** Synchronous signal, available before the persisted flag loads; true on upgrades. */ + val isExistingInstall: Boolean get() = database.isExistingInstall + + private val onboardingCompleted: StateFlow = database.settingsDataStore.data + .map { prefs -> prefs[ONBOARDING_COMPLETED_KEY] ?: database.isExistingInstall } + .stateIn(scope, SharingStarted.Eagerly, null) + + /** + * Whether the app should start on the Welcome/Onboarding route instead of the main shell. + * + * - `true` on a fresh install, or whenever [DebugConfig.FORCE_SHOW_ONBOARDING] is enabled. + * - `false` on an existing install (upgrade) or once onboarding is completed or skipped, + * including within a forced session. + * + * The initial value is derived from [isExistingInstall] so the start route can be chosen on + * the very first frame without flashing the wrong screen. + */ + val shouldShowOnboarding: StateFlow = combine( + onboardingCompleted, + sessionDismissed, + ) { completed, dismissed -> + when { + dismissed -> false + DebugConfig.FORCE_SHOW_ONBOARDING -> true + else -> !(completed ?: database.isExistingInstall) + } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall, + ) + + init { + // One-time migration. When the flag was never written this is either a brand-new install + // (show onboarding) or an upgrade of an existing install (skip it). Persisting the + // decision also stops the fresh install's own data writes from being mistaken for an + // upgrade on the next launch. + scope.launch { + database.settingsDataStore.edit { prefs -> + if (ONBOARDING_COMPLETED_KEY !in prefs) { + prefs[ONBOARDING_COMPLETED_KEY] = database.isExistingInstall + } + } + } + } + + suspend fun completeOnboarding() { + sessionDismissed.value = true + database.settingsDataStore.edit { prefs -> + prefs[ONBOARDING_COMPLETED_KEY] = true + } + } + + /** Development helper to surface the welcome screen again on the next launch. */ + suspend fun resetOnboarding() { + database.settingsDataStore.edit { prefs -> + prefs[ONBOARDING_COMPLETED_KEY] = false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt new file mode 100644 index 00000000..94c2055e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt @@ -0,0 +1,245 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.welcome + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.ui.base.GhostButton +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.welcome.pages.BrandingPage +import dev.krtirtho.spotube.modules.welcome.pages.DonationPage +import dev.krtirtho.spotube.modules.welcome.pages.HowItWorksPage +import dev.krtirtho.spotube.modules.welcome.pages.PluginsPage +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_back +import spotube.composeapp.generated.resources.welcome_skip + +/** + * First-run Welcome/Onboarding screen. It is a route ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) + * rendered inside the app shell. Uses the application's base controls and keeps plugin setup + * in-place, with a neutral palette independent of the user's chosen accent. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WelcomeScreen(viewModel: WelcomeViewModel) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + WelcomeTheme { + Box(Modifier.fillMaxSize()) { + WelcomeBackground() + Scaffold( + containerColor = Color.Transparent, + topBar = { + ApplicationMainBar( + title = { + WelcomeWordmark( + fontSize = MaterialTheme.typography.titleLarge.fontSize, + ) + }, + backButton = false, + transparent = true, + actions = { + if (!state.isLastPage) { + GhostButton(onClick = viewModel::completeOnboarding) { + Text( + text = stringResource(Res.string.welcome_skip), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) + }, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize()) { + + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Central stage (animated page content) + Box( + modifier = Modifier + .weight(1f) + .widthIn(max = 1280.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + modifier = Modifier.fillMaxSize(), + targetState = state.currentPageIndex, + transitionSpec = { + if (targetState > initialState) { + (slideInHorizontally { it / 4 } + fadeIn()) togetherWith + (slideOutHorizontally { -it / 4 } + fadeOut()) + } else { + (slideInHorizontally { -it / 4 } + fadeIn()) togetherWith + (slideOutHorizontally { it / 4 } + fadeOut()) + } + }, + label = "welcome_page", + ) { index -> + when (index) { + 0 -> BrandingPage() + + 1 -> DonationPage( + donations = state.donations, + onDonate = viewModel::openDonation, + ) + + 2 -> PluginsPage() + + else -> HowItWorksPage( + isPlaying = state.demoPlaying, + onTogglePlayback = viewModel::toggleDemo + ) + } + } + } + + Spacer(Modifier.height(10.dp)) + + // Bottom precision navigation bar: Back | Page Dots | Action Button + Row( + modifier = Modifier + .widthIn(max = 680.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Left: Back button + if (state.currentPageIndex > 0) { + OutlineButton( + onClick = viewModel::previousPage, + ) { + Text( + text = stringResource(Res.string.welcome_back), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } else { + Spacer(Modifier.width(76.dp)) + } + + // Center: Precision page dots + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + PageIndicator( + pageCount = state.pageCount, + currentPageIndex = state.currentPageIndex, + ) + } + + // Right: Primary Next / Let's Go action + OutlineButton( + onClick = viewModel::onPrimary, + ) { + Text( + text = stringResource(state.primaryLabel), + fontWeight = FontWeight.SemiBold, + ) + Icon( + imageVector = if (state.isLastPage) { + Iconsax.IconsaxCheckCircle + } else { + Iconsax.IconsaxNext + }, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } + } + } +} + +@Composable +private fun PageIndicator( + pageCount: Int, + currentPageIndex: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pageCount) { index -> + val selected = index == currentPageIndex + val width by animateDpAsState( + targetValue = if (selected) 20.dp else 6.dp, + label = "welcome_page_indicator", + ) + Box( + modifier = Modifier + .size(width = width, height = 6.dp) + .clip(CircleShape) + .background( + if (selected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.outlineVariant + } + ), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt new file mode 100644 index 00000000..b2ee940a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt @@ -0,0 +1,87 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.welcome + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class WelcomeViewModel( + private val repository: WelcomeRepository, +) : ViewModel() { + private val currentPageIndex = MutableStateFlow(0) + private val demoPlaying = MutableStateFlow(true) + + val uiState: StateFlow = + combine(currentPageIndex, demoPlaying) { index, playing -> + val pageCount = welcomePrimaryLabels.size + WelcomeUiState( + currentPageIndex = index, + pageCount = pageCount, + isLastPage = index == pageCount - 1, + primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], + donations = welcomeDonations, + demoPlaying = playing, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = WelcomeUiState( + currentPageIndex = 0, + pageCount = welcomePrimaryLabels.size, + isLastPage = welcomePrimaryLabels.size == 1, + primaryLabel = welcomePrimaryLabels.first(), + donations = welcomeDonations, + ), + ) + + fun nextPage() { + currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) } + } + + fun toggleDemo() { + demoPlaying.update { !it } + } + + fun onPrimary() { + if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage() + } + + fun openDonation(url: String) { + // Only destinations owned by our static funding list can be opened here. + if (welcomeDonations.none { it.url == url }) return + runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) } + } + + fun previousPage() { + currentPageIndex.update { (it - 1).coerceAtLeast(0) } + } + + fun completeOnboarding() { + viewModelScope.launch { + repository.completeOnboarding() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt new file mode 100644 index 00000000..d5e50838 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt @@ -0,0 +1,91 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.welcome + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.TextUnit +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme +import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +/** App-native controls and surfaces, in neutral charcoal/ivory instead of the user's accent. */ +@Composable +fun WelcomeTheme(content: @Composable () -> Unit) { + val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val scheme = if (dark) darkColorScheme( + primary = Color(0xFF8BA8DD), onPrimary = Color(0xFF10213E), + primaryContainer = Color(0xFF263754), onPrimaryContainer = Color(0xFFD9E4F7), + secondary = Color(0xFFBDBDBD), secondaryContainer = Color(0xFF343434), + onSecondaryContainer = Color(0xFFF0F0F0), + surface = Color(0xFF181818), background = Color(0xFF181818), + surfaceContainer = Color(0xFF222222), surfaceContainerHigh = Color(0xFF292929), + surfaceContainerHighest = Color(0xFF323232), + onSurface = Color(0xFFEDEBE7), onSurfaceVariant = Color(0xFFABA9A5), + outlineVariant = Color(0xFF414141), + ) else lightColorScheme( + primary = Color(0xFF244879), onPrimary = Color.White, + primaryContainer = Color(0xFFDDE5F1), onPrimaryContainer = Color(0xFF173252), + secondaryContainer = Color(0xFFE5E3DF), onSecondaryContainer = Color(0xFF292929), + surface = Color(0xFFF4F3F0), background = Color(0xFFF4F3F0), + surfaceContainer = Color(0xFFEDEBE7), surfaceContainerHigh = Color(0xFFFDFCFA), + surfaceContainerHighest = Color(0xFFE1DFDB), + onSurface = Color(0xFF252525), onSurfaceVariant = Color(0xFF696762), + outlineVariant = Color(0xFFD2D0CC), + ) + MaterialTheme(colorScheme = scheme) { + CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) { + content() + } + } +} + +@Composable +fun WelcomeBackground(modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + Box( + modifier.fillMaxSize().background( + Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface)), + ) + ) +} + +@Composable +fun WelcomeWordmark( + modifier: Modifier = Modifier, + fontSize: TextUnit = MaterialTheme.typography.displaySmall.fontSize +) { + Text( + stringResource(Res.string.welcome_app_name), + modifier = modifier, + fontFamily = FontFamily(Font(Res.font.cookie_regular)), + fontSize = fontSize, + color = MaterialTheme.colorScheme.onSurface, + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt new file mode 100644 index 00000000..c0f432f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt @@ -0,0 +1,46 @@ +package dev.krtirtho.spotube.modules.welcome.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.krtirtho.spotube.modules.welcome.WelcomeWordmark +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun BrandingPage(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp)) + Spacer(Modifier.height(24.dp)) + WelcomeWordmark(fontSize = 48.sp) + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.welcome_brand_freedom), + style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Text( + stringResource(Res.string.welcome_brand_quiet_description), + modifier = Modifier.widthIn(max = 440.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt new file mode 100644 index 00000000..39dcbaf5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt @@ -0,0 +1,70 @@ +package dev.krtirtho.spotube.modules.welcome.pages + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.modules.welcome.DonationOption +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DonationPage( + donations: List, + onDonate: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + stringResource(Res.string.welcome_donate_quiet_title), + style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + Text( + stringResource(Res.string.welcome_donate_letter), + modifier = Modifier.widthIn(max = 540.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + donations.forEach { option -> + OutlineButton(onClick = { onDonate(option.url) }) { + Icon( + option.icon, null, Modifier.size(24.dp), + tint = if (option.id == "patreon") MaterialTheme.colorScheme.onSurface else Color.Unspecified, + ) + Text(stringResource(option.title)) + } + } + } + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.welcome_donate_optional), + modifier = Modifier.widthIn(max = 500.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt new file mode 100644 index 00000000..bfd8a2b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -0,0 +1,181 @@ +package dev.krtirtho.spotube.modules.welcome.pages + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.base.Card +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.resources.iconsax.* +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* +import kotlin.math.PI +import kotlin.math.sin + +@Composable +fun HowItWorksPage( + isPlaying: Boolean, + onTogglePlayback: () -> Unit, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier.fillMaxSize()) { + val wide = maxWidth >= 760.dp + if (wide) { + Row( + Modifier.fillMaxSize().padding(40.dp), + horizontalArrangement = Arrangement.spacedBy(48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f)) + ListeningIntroduction(Modifier.weight(1f)) + } + } else { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically), + ) { + ListeningIntroduction() + PlayerDemonstration(isPlaying, onTogglePlayback) + } + } + } +} + +@Composable +private fun ListeningIntroduction(modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { + Text( + stringResource(Res.string.welcome_how_quiet_title), + style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif), + ) + Text( + stringResource(Res.string.welcome_how_quiet_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Silent, illustrative player. The ViewModel owns the play/pause interaction. */ +@Composable +private fun PlayerDemonstration( + isPlaying: Boolean, + onTogglePlayback: () -> Unit, + modifier: Modifier = Modifier +) { + val motion = rememberInfiniteTransition(label = "welcome_record") + val rotation by motion.animateFloat( + 0f, 360f, + infiniteRepeatable(tween(12000, easing = LinearEasing)), label = "record_rotation" + ) + val phase by motion.animateFloat( + 0f, (2 * PI).toFloat(), + infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter" + ) + val scheme = MaterialTheme.colorScheme + + Card(modifier.fillMaxWidth()) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { + Text( + stringResource(if (isPlaying) Res.string.welcome_demo_streaming else Res.string.welcome_demo_paused), + style = MaterialTheme.typography.labelSmall, + color = scheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + Canvas(Modifier.size(104.dp).rotate(if (isPlaying) rotation else 0f)) { + val radius = size.minDimension / 2f + drawCircle(Color(0xFF171717)) + for (groove in 1..8) { + drawCircle( + Color(0xFF343434), + radius * (0.4f + groove * 0.065f), + style = Stroke(1f) + ) + } + drawCircle(Color(0xFF334A6B), radius * 0.32f) + drawCircle(Color(0xFFE1DDD5), radius * 0.045f) + // An off-centre label mark makes the turntable's motion visible. + drawCircle( + Color(0xFFD6CEC0), + radius * 0.025f, + Offset(center.x, center.y - radius * 0.2f) + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(Res.string.welcome_how_mockup_title), + style = MaterialTheme.typography.titleLarge + ) + Text( + stringResource(Res.string.welcome_how_mockup_artist), + style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant + ) + Canvas(Modifier.fillMaxWidth().height(32.dp)) { + val count = 18 + val barWidth = size.width / (count * 1.7f) + repeat(count) { index -> + val wave = if (isPlaying) (sin(phase + index * 0.6f) + 1f) / 2f else 0f + val height = size.height * (0.12f + wave * 0.88f) + drawRect( + scheme.primary, + Offset(index * size.width / count, size.height - height), + androidx.compose.ui.geometry.Size(barWidth, height) + ) + } + } + } + } + Text( + stringResource(Res.string.welcome_how_mockup_lyric), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif), + color = scheme.onSurfaceVariant + ) + Row( + Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Iconsax.IconsaxPrevious, + null, + Modifier.size(18.dp), + tint = scheme.onSurfaceVariant + ) + Spacer(Modifier.width(24.dp)) + GhostIconButton(onClick = onTogglePlayback, modifier = Modifier.size(48.dp)) { + Icon( + if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + stringResource(if (isPlaying) Res.string.welcome_demo_pause_action else Res.string.welcome_demo_play_action), + Modifier.size(24.dp) + ) + } + Spacer(Modifier.width(24.dp)) + Icon( + Iconsax.IconsaxNext, + null, + Modifier.size(18.dp), + tint = scheme.onSurfaceVariant + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt new file mode 100644 index 00000000..ab3e7477 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt @@ -0,0 +1,10 @@ +package dev.krtirtho.spotube.modules.welcome.pages + +import androidx.compose.runtime.Composable +import dev.krtirtho.spotube.modules.plugin.PluginScreen + +/** The same installation, discovery, permissions and sign-in flow as Settings, in-place. */ +@Composable +fun PluginsPage() { + PluginScreen(onboarding = true) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt new file mode 100644 index 00000000..67779257 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt @@ -0,0 +1,264 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.BrandBuyMeACoffee: ImageVector + get() { + if (_BrandBuyMeACoffee != null) { + return _BrandBuyMeACoffee!! + } + _BrandBuyMeACoffee = ImageVector.Builder( + name = "BrandBuyMeACoffee", + defaultWidth = 512.dp, + defaultHeight = 512.dp, + viewportWidth = 512f, + viewportHeight = 512f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(512f) + verticalLineToRelative(512f) + horizontalLineTo(0f) + close() + } + ) { + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.17f, 256.45f) + lineToRelative(-0.35f, -0.21f) + lineTo(255f, 256f) + arcToRelative(1.93f, 1.93f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.16f, 0.45f) + close() + moveTo(256.15f, 256.04f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.15f, -0.04f) + arcToRelative(0.54f, 0.54f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 0.1f) + arcToRelative(0.3f, 0.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0.15f, -0.06f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256f, 256.03f) + horizontalLineToRelative(0.05f) + verticalLineTo(256f) + lineToRelative(-0.05f, 0.03f) + close() + moveTo(255f, 256.67f) + lineToRelative(0.59f, -0.34f) + lineToRelative(0.22f, -0.12f) + lineToRelative(0.2f, -0.21f) + arcToRelative(3.39f, 3.39f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.01f, 0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(255.97f, 256.77f) + lineToRelative(-0.58f, -0.55f) + lineTo(255f, 256f) + curveToRelative(0.21f, 0.37f, 0.56f, 0.65f, 0.97f, 0.76f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.18f, 256f) + curveToRelative(-0.46f, 0.2f, -0.87f, 0.51f, -1.18f, 0.91f) + lineToRelative(0.37f, -0.23f) + curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.2f, 255.27f) + curveToRelative(0f, -0.52f, -0.25f, -0.43f, -0.19f, 1.43f) + curveToRelative(0f, -0.15f, 0.06f, -0.3f, 0.09f, -0.44f) + curveToRelative(0.04f, -0.33f, 0.06f, -0.66f, 0.1f, -0.99f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.18f, 256f) + arcToRelative(3.02f, 3.02f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.18f, 0.91f) + lineToRelative(0.37f, -0.23f) + curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.24f, 256.58f) + arcTo(2.52f, 2.52f, 0f, isMoreThanHalf = false, isPositiveArc = false, 255f, 256f) + curveToRelative(0.37f, 0.18f, 0.74f, 0.36f, 0.99f, 0.5f) + lineToRelative(0.25f, 0.08f) + close() + moveTo(256.49f, 256.54f) + arcTo(3.96f, 3.96f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 255f) + curveToRelative(0.19f, 0.49f, 0.35f, 1f, 0.47f, 1.51f) + lineToRelative(0.01f, 0.03f) + close() + } + path(fill = SolidColor(Color(0xFFFFDD00))) { + moveTo(267.82f, 145.08f) + curveToRelative(-18.4f, 7.87f, -39.28f, 16.8f, -66.33f, 16.8f) + arcTo(125.64f, 125.64f, 0f, isMoreThanHalf = false, isPositiveArc = true, 168f, 157.26f) + lineToRelative(18.71f, 192.07f) + arcToRelative(32.09f, 32.09f, 0f, isMoreThanHalf = false, isPositiveArc = false, 32f, 29.45f) + reflectiveCurveToRelative(26.53f, 1.38f, 35.39f, 1.38f) + curveToRelative(9.53f, 0f, 38.1f, -1.38f, 38.1f, -1.38f) + arcToRelative(32.1f, 32.1f, 0f, isMoreThanHalf = false, isPositiveArc = false, 31.99f, -29.45f) + lineToRelative(20.04f, -212.25f) + curveToRelative(-8.96f, -3.06f, -18f, -5.09f, -28.19f, -5.09f) + curveToRelative(-17.63f, -0.01f, -31.83f, 6.06f, -48.23f, 13.08f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256f, 256f) + lineToRelative(0.32f, 0.3f) + lineToRelative(0.21f, 0.12f) + arcTo(3.21f, 3.21f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 256f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(431.23f, 136.85f) + lineToRelative(-2.82f, -14.21f) + curveToRelative(-2.53f, -12.75f, -8.27f, -24.8f, -21.36f, -29.41f) + curveToRelative(-4.2f, -1.47f, -8.96f, -2.11f, -12.18f, -5.16f) + curveToRelative(-3.22f, -3.05f, -4.17f, -7.79f, -4.91f, -12.18f) + curveToRelative(-1.38f, -8.07f, -2.67f, -16.14f, -4.09f, -24.19f) + curveToRelative(-1.22f, -6.92f, -2.18f, -14.7f, -5.36f, -21.05f) + curveToRelative(-4.13f, -8.53f, -12.71f, -13.51f, -21.24f, -16.81f) + arcToRelative(122.38f, 122.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, -13.36f, -4.13f) + curveToRelative(-21.31f, -5.62f, -43.72f, -7.69f, -65.64f, -8.86f) + arcToRelative(551.32f, 551.32f, 0f, isMoreThanHalf = false, isPositiveArc = false, -78.96f, 1.31f) + curveToRelative(-19.54f, 1.78f, -40.12f, 3.93f, -58.69f, 10.68f) + curveToRelative(-6.79f, 2.47f, -13.78f, 5.44f, -18.94f, 10.68f) + curveToRelative(-6.33f, 6.44f, -8.4f, 16.4f, -3.78f, 24.43f) + curveToRelative(3.29f, 5.7f, 8.85f, 9.73f, 14.76f, 12.4f) + arcToRelative(119.72f, 119.72f, 0f, isMoreThanHalf = false, isPositiveArc = false, 23.97f, 7.8f) + curveToRelative(22.94f, 5.07f, 46.71f, 7.06f, 70.15f, 7.91f) + arcToRelative(534.38f, 534.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, 77.86f, -2.54f) + arcToRelative(443.55f, 443.55f, 0f, isMoreThanHalf = false, isPositiveArc = false, 19.15f, -2.53f) + curveToRelative(7.5f, -1.15f, 12.32f, -10.96f, 10.11f, -17.79f) + curveToRelative(-2.65f, -8.17f, -9.76f, -11.34f, -17.8f, -10.1f) + curveToRelative(-1.18f, 0.19f, -2.36f, 0.36f, -3.55f, 0.53f) + lineToRelative(-0.85f, 0.12f) + curveToRelative(-2.72f, 0.34f, -5.45f, 0.67f, -8.17f, 0.96f) + arcToRelative(433.76f, 433.76f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.92f, 1.49f) + curveToRelative(-12.66f, 0.88f, -25.36f, 1.29f, -38.06f, 1.31f) + curveToRelative(-12.47f, 0f, -24.95f, -0.35f, -37.39f, -1.17f) + arcToRelative(475.74f, 475.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.99f, -1.42f) + curveToRelative(-2.57f, -0.27f, -5.13f, -0.55f, -7.7f, -0.87f) + lineToRelative(-2.44f, -0.31f) + lineToRelative(-0.53f, -0.08f) + lineToRelative(-2.53f, -0.37f) + curveToRelative(-5.17f, -0.78f, -10.34f, -1.67f, -15.45f, -2.76f) + arcToRelative(2.32f, 2.32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, -4.53f) + horizontalLineToRelative(0.1f) + arcToRelative(250.82f, 250.82f, 0f, isMoreThanHalf = false, isPositiveArc = true, 13.37f, -2.45f) + curveToRelative(1.49f, -0.23f, 2.99f, -0.46f, 4.49f, -0.69f) + horizontalLineToRelative(0.04f) + curveToRelative(2.81f, -0.19f, 5.62f, -0.69f, 8.41f, -1.02f) + arcToRelative(535.29f, 535.29f, 0f, isMoreThanHalf = false, isPositiveArc = true, 73.1f, -2.58f) + curveToRelative(11.84f, 0.34f, 23.68f, 1.04f, 35.47f, 2.24f) + arcToRelative(391.9f, 391.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7.58f, 0.85f) + curveToRelative(0.96f, 0.12f, 1.94f, 0.25f, 2.91f, 0.37f) + lineToRelative(1.96f, 0.28f) + curveToRelative(5.7f, 0.85f, 11.38f, 1.88f, 17.03f, 3.09f) + curveToRelative(8.36f, 1.82f, 19.11f, 2.41f, 22.83f, 11.57f) + curveToRelative(1.18f, 2.91f, 1.72f, 6.14f, 2.38f, 9.19f) + lineToRelative(0.83f, 3.89f) + curveToRelative(0.02f, 0.07f, 0.04f, 0.14f, 0.05f, 0.21f) + curveToRelative(1.97f, 9.18f, 3.94f, 18.37f, 5.92f, 27.55f) + arcToRelative(5.05f, 5.05f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.24f, 6.05f) + horizontalLineToRelative(-0.05f) + lineToRelative(-1.21f, 0.17f) + lineToRelative(-1.19f, 0.16f) + curveToRelative(-3.78f, 0.49f, -7.56f, 0.95f, -11.34f, 1.38f) + arcToRelative(732.52f, 732.52f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.4f, 2.2f) + arcToRelative(784.15f, 784.15f, 0f, isMoreThanHalf = false, isPositiveArc = true, -44.67f, 2.43f) + arcToRelative(802.63f, 802.63f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.8f, 0.28f) + arcToRelative(791.3f, 791.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, -90.5f, -5.26f) + arcToRelative(944.16f, 944.16f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.76f, -1.22f) + curveToRelative(2.52f, 0.32f, -1.83f, -0.25f, -2.71f, -0.37f) + curveToRelative(-2.07f, -0.29f, -4.13f, -0.59f, -6.2f, -0.9f) + curveToRelative(-6.94f, -1.04f, -13.84f, -2.32f, -20.76f, -3.44f) + curveToRelative(-8.37f, -1.38f, -16.38f, -0.69f, -23.95f, 3.44f) + arcToRelative(34.83f, 34.83f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.42f, 14.95f) + curveToRelative(-3.27f, 6.75f, -4.24f, 14.1f, -5.7f, 21.35f) + curveToRelative(-1.46f, 7.25f, -3.73f, 15.06f, -2.87f, 22.5f) + curveToRelative(1.85f, 16.07f, 13.09f, 29.13f, 29.26f, 32.05f) + curveToRelative(15.21f, 2.76f, 30.5f, 4.99f, 45.83f, 6.89f) + arcToRelative(847.2f, 847.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 196.22f, 1.13f) + arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11.41f, 11.32f) + lineToRelative(-1.53f, 14.86f) + lineToRelative(-9.25f, 90.1f) + arcToRelative(70942.69f, 70942.69f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.71f, 94.6f) + lineToRelative(-2.76f, 26.64f) + curveToRelative(-0.88f, 8.74f, -1.01f, 17.76f, -2.67f, 26.4f) + curveToRelative(-2.62f, 13.58f, -11.82f, 21.93f, -25.24f, 24.98f) + arcToRelative(175.93f, 175.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, -37.47f, 4.38f) + curveToRelative(-13.98f, 0.08f, -27.95f, -0.54f, -41.94f, -0.47f) + curveToRelative(-14.93f, 0.08f, -33.2f, -1.29f, -44.72f, -12.4f) + curveToRelative(-10.12f, -9.75f, -11.52f, -25.02f, -12.9f, -38.23f) + arcToRelative(29492.9f, 29492.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, -5.46f, -52.44f) + lineToRelative(-10.13f, -97.18f) + lineToRelative(-6.55f, -62.88f) + curveToRelative(-0.11f, -1.04f, -0.22f, -2.07f, -0.32f, -3.11f) + curveToRelative(-0.79f, -7.5f, -6.1f, -14.84f, -14.47f, -14.46f) + curveToRelative(-7.17f, 0.32f, -15.31f, 6.41f, -14.47f, 14.46f) + lineToRelative(4.86f, 46.62f) + lineToRelative(10.05f, 96.43f) + curveToRelative(2.86f, 27.39f, 5.72f, 54.79f, 8.56f, 82.19f) + curveToRelative(0.55f, 5.25f, 1.07f, 10.51f, 1.65f, 15.76f) + curveToRelative(3.15f, 28.68f, 25.06f, 44.14f, 52.19f, 48.49f) + curveToRelative(15.85f, 2.55f, 32.08f, 3.08f, 48.16f, 3.34f) + curveToRelative(20.62f, 0.33f, 41.44f, 1.13f, 61.72f, -2.61f) + curveToRelative(30.05f, -5.51f, 52.6f, -25.57f, 55.81f, -56.68f) + lineToRelative(2.76f, -26.95f) + curveToRelative(3.05f, -29.72f, 6.11f, -59.44f, 9.15f, -89.17f) + lineToRelative(9.96f, -97.12f) + lineToRelative(4.57f, -44.51f) + arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.32f, -9.09f) + curveToRelative(8.59f, -1.67f, 16.81f, -4.53f, 22.92f, -11.07f) + curveToRelative(9.73f, -10.41f, 11.66f, -23.98f, 8.23f, -37.66f) + close() + moveTo(108.01f, 146.45f) + curveToRelative(0.13f, -0.06f, -0.11f, 1.06f, -0.21f, 1.58f) + curveToRelative(-0.02f, -0.79f, 0.02f, -1.49f, 0.21f, -1.58f) + close() + moveTo(108.84f, 152.9f) + curveToRelative(0.07f, -0.05f, 0.28f, 0.23f, 0.49f, 0.56f) + curveToRelative(-0.32f, -0.3f, -0.53f, -0.53f, -0.5f, -0.56f) + horizontalLineToRelative(0.01f) + close() + moveTo(109.66f, 153.98f) + curveToRelative(0.45f, 0.82f, 0.3f, 0.5f, 0f, 0f) + close() + moveTo(111.31f, 155.31f) + horizontalLineToRelative(0.04f) + curveToRelative(0f, 0.05f, 0.08f, 0.1f, 0.1f, 0.14f) + arcToRelative(1.04f, 1.04f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.15f, -0.14f) + horizontalLineToRelative(0.01f) + close() + moveTo(399.68f, 153.32f) + curveToRelative(-3.09f, 2.93f, -7.74f, 4.3f, -12.33f, 4.98f) + curveToRelative(-51.54f, 7.64f, -103.83f, 11.52f, -155.94f, 9.81f) + curveToRelative(-37.29f, -1.27f, -74.19f, -5.41f, -111.11f, -10.63f) + curveToRelative(-3.62f, -0.51f, -7.54f, -1.17f, -10.02f, -3.84f) + curveToRelative(-4.69f, -5.03f, -2.38f, -15.15f, -1.16f, -21.23f) + curveToRelative(1.12f, -5.57f, 3.25f, -12.98f, 9.87f, -13.78f) + curveToRelative(10.34f, -1.21f, 22.34f, 3.15f, 32.56f, 4.7f) + arcToRelative(616.95f, 616.95f, 0f, isMoreThanHalf = false, isPositiveArc = false, 37.07f, 4.51f) + curveToRelative(52.93f, 4.82f, 106.75f, 4.07f, 159.45f, -2.98f) + arcToRelative(666.2f, 666.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 28.71f, -4.5f) + curveToRelative(8.5f, -1.52f, 17.92f, -4.38f, 23.05f, 4.41f) + curveToRelative(3.52f, 5.99f, 3.99f, 14.01f, 3.44f, 20.78f) + arcToRelative(11.59f, 11.59f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3.6f, 7.76f) + horizontalLineToRelative(0.01f) + close() + } + } + }.build() + + return _BrandBuyMeACoffee!! + } + +@Suppress("ObjectPropertyName") +private var _BrandBuyMeACoffee: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt new file mode 100644 index 00000000..cd5ba60f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt @@ -0,0 +1,58 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.BrandOpencollective: ImageVector + get() { + if (_BrandOpencollective != null) { + return _BrandOpencollective!! + } + _BrandOpencollective = ImageVector.Builder( + name = "BrandOpencollective", + defaultWidth = 64.dp, + defaultHeight = 64.dp, + viewportWidth = 64f, + viewportHeight = 64f + ).apply { + path( + fill = SolidColor(Color(0xFFB8D3F4)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(52.402f, 31.916f) + curveToRelative(0f, 4.03f, -1.17f, 7.895f, -3.178f, 11.087f) + lineToRelative(8.196f, 8.23f) + curveToRelative(4.014f, -5.375f, 6.523f, -12.094f, 6.523f, -19.318f) + reflectiveCurveToRelative(-2.51f, -13.942f, -6.523f, -19.318f) + lineToRelative(-8.196f, 8.23f) + curveToRelative(2.007f, 3.192f, 3.178f, 6.887f, 3.178f, 11.087f) + close() + } + path( + fill = SolidColor(Color(0xFF3385FF)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(32.004f, 52.41f) + curveToRelative(-11.207f, 0f, -20.406f, -9.24f, -20.406f, -20.493f) + reflectiveCurveToRelative(9.2f, -20.493f, 20.406f, -20.493f) + curveToRelative(4.182f, 0f, 7.86f, 1.176f, 11.04f, 3.36f) + lineToRelative(8.196f, -8.23f) + curveTo(45.887f, 2.52f, 39.197f, 0f, 32.004f, 0f) + curveTo(14.44f, 0f, 0.057f, 14.278f, 0.057f, 32.084f) + reflectiveCurveTo(14.44f, 64f, 32.004f, 64f) + curveToRelative(7.36f, 0f, 14.05f, -2.52f, 19.403f, -6.55f) + lineToRelative(-8.196f, -8.23f) + curveToRelative(-3.178f, 2.016f, -7.025f, 3.192f, -11.207f, 3.192f) + close() + } + }.build() + + return _BrandOpencollective!! + } + +@Suppress("ObjectPropertyName") +private var _BrandOpencollective: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt new file mode 100644 index 00000000..7029e29b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt @@ -0,0 +1,54 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.BrandPatreon: ImageVector + get() { + if (_BrandPatreon != null) { + return _BrandPatreon!! + } + _BrandPatreon = ImageVector.Builder( + name = "BrandPatreon", + defaultWidth = 512.dp, + defaultHeight = 512.dp, + viewportWidth = 512f, + viewportHeight = 512f + ).apply { + group( + clipPathData = PathData { + moveTo(0.38f, 0.42f) + lineToRelative(512f, 0f) + lineToRelative(0f, 512f) + lineTo(0.38f, 512.42f) + close() + } + ) { + path( + fill = SolidColor(Color.Black), + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(490.12f, 154.23f) + curveToRelative(-0.09f, -65.37f, -51.01f, -118.95f, -110.74f, -138.29f) + curveToRelative(-74.18f, -24.01f, -172.02f, -20.53f, -242.86f, 12.89f) + curveToRelative(-85.85f, 40.51f, -112.82f, 129.26f, -113.83f, 217.77f) + curveToRelative(-0.82f, 72.77f, 6.44f, 264.43f, 114.54f, 265.8f) + curveToRelative(80.33f, 1.02f, 92.29f, -102.48f, 129.45f, -152.33f) + curveToRelative(26.44f, -35.47f, 60.49f, -45.48f, 102.4f, -55.85f) + curveToRelative(72.03f, -17.83f, 121.13f, -74.68f, 121.03f, -149.99f) + close() + } + } + }.build() + + return _BrandPatreon!! + } + +@Suppress("ObjectPropertyName") +private var _BrandPatreon: ImageVector? = null diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index cd346728..fb1c63fe 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -31,6 +31,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -59,6 +61,7 @@ actual fun ApplicationMainBar( subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), backButton: Boolean, + transparent: Boolean, ) { Box( modifier = Modifier.fillMaxWidth() @@ -75,6 +78,14 @@ actual fun ApplicationMainBar( } }, subtitle = subtitle, + colors = if (transparent) { + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ) + } else { + TopAppBarDefaults.topAppBarColors() + }, modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons ) LocalWindowScope.current.WindowControls( diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt index 47639afc..37f6b279 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -29,12 +31,21 @@ actual fun ApplicationMainBar( title: @Composable (() -> Unit), subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), - backButton: Boolean + backButton: Boolean, + transparent: Boolean, ) { return TopAppBar( title = title, subtitle = subtitle, actions = actions, + colors = if (transparent) { + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ) + } else { + TopAppBarDefaults.topAppBarColors() + }, navigationIcon = { if (backButton) { ApplicationBackButton() From 5c0652b39507a857a549cd0871cebb1205abc543 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 7 Oct 2026 10:39:26 +0600 Subject: [PATCH 032/132] feat(previews): add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields --- .../core/ui/base/AutocompleteTextField.kt | 234 ++++++--- .../spotube/core/ui/base/BaseUIPreviews.kt | 89 ++++ .../krtirtho/spotube/core/ui/base/Buttons.kt | 480 ++++++++++-------- .../dev/krtirtho/spotube/core/ui/base/Card.kt | 116 ++++- .../krtirtho/spotube/core/ui/base/CheckBox.kt | 113 +++-- .../krtirtho/spotube/core/ui/base/ChipTabs.kt | 68 +++ .../krtirtho/spotube/core/ui/base/Dialog.kt | 209 +++++--- .../spotube/core/ui/base/DropdownMenu.kt | 120 ++++- .../spotube/core/ui/base/ListRowTile.kt | 110 +++- .../krtirtho/spotube/core/ui/base/Radio.kt | 56 +- .../krtirtho/spotube/core/ui/base/Slider.kt | 62 ++- .../spotube/core/ui/base/TextField.kt | 122 +++-- .../krtirtho/spotube/core/ui/base/Toggle.kt | 75 ++- .../krtirtho/spotube/core/ui/base/Utils.kt | 53 ++ 14 files changed, 1378 insertions(+), 529 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt index ca559005..670524a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -43,6 +44,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -74,7 +76,6 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -83,6 +84,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken +import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash private val AutocompleteTextFieldMinHeight = 44.dp private val AutocompleteMenuDefaultMaxHeight = 300.dp @@ -373,13 +375,11 @@ fun AutocompleteTextField( isMenuOpen = false }, ) { - Box( + AutocompleteMenuContainer( + theme = resolvedMenuTheme, modifier = Modifier .width(textFieldWidthDp) - .heightIn(max = menuMaxHeight) - .shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape) - .background(resolvedMenuTheme.background, resolvedMenuTheme.shape) - .clip(resolvedMenuTheme.shape), + .heightIn(max = menuMaxHeight), ) { LazyColumn( state = menuListState, @@ -407,77 +407,163 @@ fun AutocompleteTextField( } } -@Preview +/** + * The visual container of the autocomplete suggestion menu, without the Popup window. + * + * Extracted so the menu can be rendered in previews, since popups are hosted in a + * separate window the preview canvas cannot show. + */ +@Composable +internal fun AutocompleteMenuContainer( + theme: BaseUITheme.AutoCompleteMenuTheme, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .shadow(theme.shadowElevation, theme.shape) + .background(theme.background, theme.shape) + .clip(theme.shape), + ) { + content() + } +} + +private val AutocompletePreviewArtists = listOf( + "Twenty One Pilots", + "The Beatles", + "Adele", + "Drake", + "Taylor Swift", + "Arctic Monkeys", + "Billie Eilish", +) + +@BaseUIPhonePreview @Composable private fun AutocompleteTextFieldPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - val artists = remember { - listOf( - "Twenty One Pilots", - "The Beatles", - "Adele", - "Drake", - "Taylor Swift", - "Arctic Monkeys", - "Billie Eilish", - ) - } - var value by remember { mutableStateOf("") } + BaseUIPreview { + var value by remember { mutableStateOf("") } - AutocompleteTextField( - value = value, - onValueChange = { - value = it - }, - items = artists.filter { - it.contains(value, ignoreCase = true) && value.isNotEmpty() - }, - onItemSelected = { selected -> - value = selected - }, - placeholder = { Text("Search artists...") }, - leadingIcon = { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - }, - modifier = Modifier.fillMaxWidth(), - itemContent = { item, isSelected -> - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = if (isSelected) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) - } else { - Color.Transparent - }, - ) - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = item, - style = MaterialTheme.typography.bodyMedium, - ) - } - }, - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = "Field states", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + AutocompleteTextField( + value = "", + onValueChange = {}, + items = emptyList(), + itemContent = { _, _ -> }, + placeholder = { Text("Search artists...") }, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxSearchBroken, + contentDescription = "Search", + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + AutocompleteTextField( + value = value, + onValueChange = { value = it }, + items = AutocompletePreviewArtists.filter { + it.contains(value, ignoreCase = true) + }, + onItemSelected = { value = it }, + placeholder = { Text("Search artists...") }, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxSearchBroken, + contentDescription = "Search", + ) + }, + trailingIcon = { + Icon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = "Clear", + ) + }, + modifier = Modifier.fillMaxWidth(), + itemContent = { item, isSelected -> + AutocompletePreviewItem(item, isSelected) + }, + ) } } } + +@BaseUIPhonePreview +@Composable +private fun AutocompleteMenuPreview() { + BaseUIPreview { + var selectedIndex by remember { mutableStateOf(1) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Suggestion menu", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + AutocompleteMenuContainer( + theme = LocalBaseUITheme.current.autoCompleteMenuTheme, + modifier = Modifier.width(320.dp), + ) { + Column { + AutocompletePreviewArtists.take(5).forEachIndexed { index, artist -> + Box( + modifier = Modifier.clickable { selectedIndex = index }, + ) { + AutocompletePreviewItem(artist, isSelected = selectedIndex == index) + } + } + } + } + Text( + text = "Selected: ${AutocompletePreviewArtists[selectedIndex]}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AutocompletePreviewItem(text: String, isSelected: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (isSelected) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + } else { + Color.Transparent + }, + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxSearchBroken, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt new file mode 100644 index 00000000..1a5cc11c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt @@ -0,0 +1,89 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.ui.base + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview + +/** + * Shared scaffold for every preview of a base UI component. + * + * It mirrors the runtime environment the components expect: + * - a [MaterialTheme] whose light/dark mode follows the preview's `uiMode`, so the + * `@PreviewLightDark`-style annotations actually change the rendered colors, + * - a [LocalBaseUITheme] built from that color scheme via [rememberBaseUITheme], + * - a [Surface] matching the real app background, so layered highlights and shadows + * render against the color they will actually sit on. + * + * Always render base components inside [BaseUIPreview]; several of them read + * [LocalBaseUITheme] directly and would otherwise throw at composition time. + */ +@Composable +internal fun BaseUIPreview( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(), + ) { + CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(modifier = Modifier.fillMaxSize(), content = content) + } + } + } +} + +/** + * Renders a base UI component on a phone-sized canvas in both light and dark themes. + */ +@Preview(name = "Phone · Light", group = "Base UI devices", device = Devices.PHONE) +@Preview( + name = "Phone · Dark", + group = "Base UI devices", + device = Devices.PHONE, + uiMode = UI_MODE_NIGHT_YES, +) +internal annotation class BaseUIPhonePreview + +/** + * Renders a base UI component on a desktop-sized canvas in both light and dark themes. + */ +@Preview(name = "Desktop · Light", group = "Base UI devices", device = Devices.DESKTOP) +@Preview( + name = "Desktop · Dark", + group = "Base UI devices", + device = Devices.DESKTOP, + uiMode = UI_MODE_NIGHT_YES, +) +internal annotation class BaseUIDesktopPreview diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index 885f6357..430b2899 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -46,7 +47,10 @@ import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -57,7 +61,6 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 @@ -549,244 +552,281 @@ fun ButtonGroupDivider( ) } -@Preview(showBackground = true) +@BaseUIPhonePreview +@BaseUIDesktopPreview @Composable -private fun ButtonsRow1Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), +private fun ButtonsPreview() { + BaseUIPreview { + var liked by remember { mutableStateOf(false) } + var likeCount by remember { mutableIntStateOf(128) } + var following by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + OutlineButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxShare, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text("Share", maxLines = 1, softWrap = false) + } + SecondaryButton(onClick = {}) { + Text("Secondary", maxLines = 1, softWrap = false) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PrimaryButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text( + "Sign up", + maxLines = 1, + softWrap = false, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ) + } + GhostButton(onClick = {}) { + Text("Ghost", maxLines = 1, softWrap = false) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlineButton(onClick = {}, enabled = false) { + Text("Disabled", maxLines = 1, softWrap = false) + } + SecondaryButton(onClick = {}, enabled = false) { + Text("Disabled", maxLines = 1, softWrap = false) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PrimaryButton(onClick = {}, enabled = false) { + Text("Disabled", maxLines = 1, softWrap = false) + } + OutlineButton(onClick = {}) { + Text("Forward", maxLines = 1, softWrap = false) + Icon( + imageVector = Iconsax.IconsaxArrowSquareUp, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlineButton( + onClick = { + liked = !liked + likeCount += if (liked) 1 else -1 + }, ) { - OutlineButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxHeart, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text(if (liked) "Liked" else "Like", maxLines = 1, softWrap = false) + ButtonBadge(count = likeCount) + } + PrimaryButton(onClick = { following = !following }) { + Text( + if (following) "Following" else "Follow", + maxLines = 1, + softWrap = false, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ) + } + } + } + } +} + +@BaseUIPhonePreview +@Composable +private fun IconButtonsPreview() { + BaseUIPreview { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Icon buttons", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + IconButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxMagic, + contentDescription = "Magic", + modifier = Modifier.size(18.dp), + ) + } + GhostIconButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxHeart, + contentDescription = "Favorite", + modifier = Modifier.size(18.dp), + ) + } + PrimaryIconButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = "Add", + modifier = Modifier.size(18.dp), + ) + } + SecondaryIconButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxShare, + contentDescription = "Share", + modifier = Modifier.size(18.dp), + ) + } + } + + Text( + text = "Variable icon button", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + VariableIconButton( + variant = VariableIconButtonVariant.Outline, + onClick = {}, + ) { + Icon( + imageVector = Iconsax.IconsaxDocumentText, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + VariableIconButton( + variant = VariableIconButtonVariant.Ghost, + onClick = {}, + ) { + Icon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + VariableIconButton( + variant = VariableIconButtonVariant.Primary, + onClick = {}, + ) { + Icon( + imageVector = Iconsax.IconsaxMagic, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + VariableIconButton( + variant = VariableIconButtonVariant.Secondary, + onClick = {}, + ) { + Icon( + imageVector = Iconsax.User, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + } + } +} + +@BaseUIDesktopPreview +@Composable +private fun ButtonGroupPreview() { + BaseUIPreview { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Text( + text = "Button groups", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ButtonGroup { + GroupButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxDocumentText, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text("Documents", maxLines = 1, softWrap = false) + } + ButtonGroupDivider() + GroupButton(onClick = {}) { Icon( imageVector = Iconsax.IconsaxShare, contentDescription = null, modifier = Modifier.size(18.dp), ) - Text( - "Copy link", - maxLines = 1, - softWrap = false, - ) + Text("Export", maxLines = 1, softWrap = false) } - OutlineButton(onClick = {}) { + ButtonGroupDivider() + GroupIconButton(onClick = {}) { Icon( - imageVector = Iconsax.User, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Login", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text( - "Sign Up", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow2Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - ButtonGroup { - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxDocumentText, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Documents", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Export", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - ButtonGroup { - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.ArrowLeft3, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow3Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}) { - Text("Cancel", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Text( - "Done", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - IconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxMagic, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - OutlineButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxHeart, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Like", maxLines = 1, softWrap = false) - ButtonBadge(count = 2) - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow4Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - ButtonGroup { - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.ArrowLeft3, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - OutlineButton(onClick = {}) { - Text("Forward", maxLines = 1, softWrap = false) - Icon( - imageVector = Iconsax.IconsaxArrowSquareUp, + imageVector = Iconsax.Iconsax3DotsMore, contentDescription = null, modifier = Modifier.size(18.dp), ) } } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonStylesPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}) { - Text("Outline", maxLines = 1, softWrap = false) + ButtonGroup { + GroupIconButton(onClick = {}) { + Icon( + imageVector = Iconsax.ArrowLeft3, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) } - SecondaryButton(onClick = {}) { - Text("Secondary", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Text( - "Primary", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ButtonGroupDivider() + GroupIconButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = null, + modifier = Modifier.size(18.dp), ) } } + ButtonBadge(count = 8) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt index 61e23930..cdb7f54e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt @@ -20,18 +20,38 @@ package dev.krtirtho.spotube.core.ui.base import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @Composable fun Card( @@ -62,18 +82,90 @@ fun Card( } } +@BaseUIPhonePreview @Composable -@androidx.compose.ui.tooling.preview.Preview private fun CardPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Card { - Box(modifier = Modifier.padding(16.dp)) + BaseUIPreview { + var connected by remember { mutableStateOf(false) } + var playing by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = if (connected) "Spotify connected" else "Connect a music source", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = if (connected) { + "Streaming, downloads and scrobbling are ready." + } else { + "Sign in to stream, download and scrobble from your account." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlineButton(onClick = { connected = false }) { + Text("Disconnect") + } + PrimaryButton(onClick = { connected = !connected }) { + Text(if (connected) "Connected" else "Connect") + } + } + } + } + + Card { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusic, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Midnight City", + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + ) + Text( + text = "M83 · Hurry Up, We're Dreaming", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + PrimaryIconButton(onClick = { playing = !playing }) { + Icon( + imageVector = if (playing) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = if (playing) "Pause" else "Play", + modifier = Modifier.size(18.dp), + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt index 23d66e55..af1f0643 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt @@ -31,17 +31,20 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -56,7 +59,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp enum class CheckBoxState { @@ -207,47 +209,72 @@ fun CheckBox( } } -@Preview +@BaseUIPhonePreview @Composable -fun CheckBoxPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.SELECTED, onClick = {}) - Text("Selected", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.UNSELECTED, onClick = {}) - Text("Unselected", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {}) - Text("Indeterminate", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false) - Text("Disabled", style = MaterialTheme.typography.bodyMedium) - } - } - } +private fun CheckBoxPreview() { + BaseUIPreview { + var wifi by remember { mutableStateOf(true) } + var bluetooth by remember { mutableStateOf(false) } + var downloads by remember { mutableStateOf(true) } + + val allEnabled = wifi && bluetooth && downloads + val anyEnabled = wifi || bluetooth || downloads + val allState = when { + allEnabled -> CheckBoxState.SELECTED + anyEnabled -> CheckBoxState.INDETERMINATE + else -> CheckBoxState.UNSELECTED + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CheckBoxPreviewRow( + state = allState, + label = "All playback sources", + onClick = { + val target = !allEnabled + wifi = target + bluetooth = target + downloads = target + }, + ) + Spacer(Modifier.height(4.dp)) + CheckBoxPreviewRow( + state = if (wifi) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, + label = "Wi-Fi", + onClick = { wifi = !wifi }, + ) + CheckBoxPreviewRow( + state = if (bluetooth) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, + label = "Bluetooth", + onClick = { bluetooth = !bluetooth }, + ) + CheckBoxPreviewRow( + state = if (downloads) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, + label = "Download over cellular", + onClick = { downloads = !downloads }, + ) + CheckBoxPreviewRow(CheckBoxState.SELECTED, "Disabled selected", enabled = false) + CheckBoxPreviewRow(CheckBoxState.UNSELECTED, "Disabled unselected", enabled = false) } } } + +@Composable +private fun CheckBoxPreviewRow( + state: CheckBoxState, + label: String, + enabled: Boolean = true, + onClick: () -> Unit = {}, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CheckBox(state = state, onClick = onClick, enabled = enabled) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt index 38963781..e8042ee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt @@ -27,10 +27,14 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text @@ -38,7 +42,9 @@ import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,6 +56,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary private val ChipTabMinHeight = 36.dp @@ -169,3 +178,62 @@ fun ChipTab( ) } } + +@BaseUIPhonePreview +@Composable +private fun ChipTabsPreview() { + BaseUIPreview { + val tabs = listOf("All", "Songs", "Albums", "Playlists") + var selected by remember { mutableStateOf(0) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + tabs.forEachIndexed { index, tab -> + ChipTab( + text = tab, + selected = selected == index, + onClick = { selected = index }, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ChipTab( + text = "Library", + selected = true, + onClick = {}, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxMusicLibrary, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + ChipTab( + text = "Downloads", + selected = false, + onClick = {}, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxDocumentDownload, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + ChipTab( + text = "Disabled", + selected = false, + onClick = {}, + enabled = false, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt index 67dd0abe..b7cd5613 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt @@ -26,11 +26,14 @@ import androidx.compose.animation.scaleOut import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -42,7 +45,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,7 +54,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -70,8 +72,6 @@ fun ThemedDialog( properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), content: @Composable () -> Unit, ) { - val dialogTheme = theme ?: LocalBaseUITheme.current.dialog - Dialog( onDismissRequest = onDismissRequest, properties = properties, @@ -93,90 +93,141 @@ fun ThemedDialog( .padding(24.dp), contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .widthIn(max = DefaultDialogMaxWidth) - .shadow( - elevation = dialogTheme.shadow.elevation, - shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, - ambientColor = dialogTheme.shadow.ambientColor, - spotColor = dialogTheme.shadow.spotColor, - ) - .clip(dialogTheme.shape) - .background(dialogTheme.background, dialogTheme.shape) - .border( - BorderStroke(dialogTheme.border.width, dialogTheme.border.color), - dialogTheme.shape - ) - .highlight(Color.White.copy(alpha = 0.08f)), - ) { - Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { - if (title != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp, top = 24.dp), - ) { - title() - } - Spacer(Modifier.height(8.dp)) - } - - Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()) - .padding( - start = 24.dp, - end = 24.dp, - top = if (title == null) 24.dp else 0.dp, - ), - ) { - content() - } - - if (actions != null) { - Spacer(Modifier.height(16.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - actions() - } - } - } else { - Spacer(Modifier.height(24.dp)) - } - } - } + ThemedDialogSurface( + theme = theme, + title = title, + actions = actions, + content = content, + ) } } } } -@Preview +/** + * The visual chrome of [ThemedDialog] without the platform [Dialog] window. + * + * Extracted so the dialog can be rendered inside previews (and reused elsewhere), + * since the platform dialog lives in a separate window the preview canvas cannot show. + */ +@Composable +internal fun ThemedDialogSurface( + modifier: Modifier = Modifier, + theme: BaseUITheme.DialogTheme? = null, + title: @Composable (() -> Unit)? = null, + actions: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val dialogTheme = theme ?: LocalBaseUITheme.current.dialog + + Box( + modifier = modifier + .widthIn(max = DefaultDialogMaxWidth) + .shadow( + elevation = dialogTheme.shadow.elevation, + shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, + ambientColor = dialogTheme.shadow.ambientColor, + spotColor = dialogTheme.shadow.spotColor, + ) + .clip(dialogTheme.shape) + .background(dialogTheme.background, dialogTheme.shape) + .border( + BorderStroke(dialogTheme.border.width, dialogTheme.border.color), + dialogTheme.shape + ) + .highlight(Color.White.copy(alpha = 0.08f)), + ) { + Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { + if (title != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 24.dp), + ) { + title() + } + Spacer(Modifier.height(8.dp)) + } + + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding( + start = 24.dp, + end = 24.dp, + top = if (title == null) 24.dp else 0.dp, + ), + ) { + content() + } + + if (actions != null) { + Spacer(Modifier.height(16.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + actions() + } + } + } else { + Spacer(Modifier.height(24.dp)) + } + } + } +} + +@BaseUIPhonePreview @Composable private fun ThemedDialogPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - var show by remember { mutableStateOf(true) } - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, + BaseUIPreview { + var visible by remember { mutableStateOf(true) } + + Box(modifier = Modifier.fillMaxSize()) { + PrimaryButton( + onClick = { visible = true }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(24.dp), ) { - PrimaryButton(onClick = { show = true }) { - Text("Open Dialog") - } - if (show) { - ThemedDialog(onDismissRequest = { show = false }) { + Text("Open dialog") + } + + if (visible) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LocalBaseUITheme.current.dialog.scrim) + .clickable { visible = false }, + contentAlignment = Alignment.Center, + ) { + ThemedDialogSurface( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + title = { + Text( + text = "Sign out of Spotube?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + OutlineButton(onClick = { visible = false }) { Text("Cancel") } + PrimaryButton(onClick = { visible = false }) { Text("Sign out") } + }, + ) { Text( - "This is a themed dialog with glass-like gradient, shadow, and scale animation.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + text = "You can sign back in at any time. Your downloaded tracks stay on this device.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt index 3ac8cc56..63dc38ec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt @@ -26,8 +26,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -38,7 +40,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,6 +51,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxShare +import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash @Composable fun DropdownMenu( @@ -63,25 +73,32 @@ fun DropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, offset = offset, - modifier = modifier - .widthIn(min = 180.dp) - .shadow( - elevation = menuTheme.shadow.elevation, - shape = menuTheme.shape, - ambientColor = menuTheme.shadow.ambientColor, - spotColor = menuTheme.shadow.spotColor, - ) - .clip(menuTheme.shape) - .background(menuTheme.background, menuTheme.shape) - .border( - BorderStroke(menuTheme.border.width, menuTheme.border.color), - menuTheme.shape, - ), + modifier = modifier.dropdownMenuContainer(menuTheme), ) { content() } } +/** + * The container styling of [DropdownMenu], split out so it can be reused by the + * material3 popup and by previews, which cannot render the popup window itself. + */ +internal fun Modifier.dropdownMenuContainer(theme: BaseUITheme.DropdownMenuTheme): Modifier = + this + .widthIn(min = 180.dp) + .shadow( + elevation = theme.shadow.elevation, + shape = theme.shape, + ambientColor = theme.shadow.ambientColor, + spotColor = theme.shadow.spotColor, + ) + .clip(theme.shape) + .background(theme.background, theme.shape) + .border( + BorderStroke(theme.border.width, theme.border.color), + theme.shape, + ) + @Composable fun DropdownMenuItem( text: String, @@ -170,3 +187,78 @@ fun DropdownMenuDivider( ) } } + +@BaseUIPhonePreview +@Composable +private fun DropdownMenuPreview() { + BaseUIPreview { + var expanded by remember { mutableStateOf(true) } + var lastAction by remember { mutableStateOf("None") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlineButton(onClick = { expanded = !expanded }) { + Text(if (expanded) "Hide menu" else "Show menu") + } + + if (expanded) { + Column( + modifier = Modifier.dropdownMenuContainer( + LocalBaseUITheme.current.dropdownMenu, + ), + ) { + DropdownMenuItem( + text = "Play next", + leadingIcon = Iconsax.IconsaxPlay, + onClick = { + lastAction = "Play next" + expanded = false + }, + ) + DropdownMenuItem( + text = "Add to queue", + leadingIcon = Iconsax.IconsaxAddSquare, + onClick = { + lastAction = "Add to queue" + expanded = false + }, + ) + DropdownMenuItem( + text = "Add to favorites", + leadingIcon = Iconsax.IconsaxHeart, + selected = lastAction == "Add to favorites", + onClick = { + lastAction = "Add to favorites" + expanded = false + }, + ) + DropdownMenuDivider() + DropdownMenuItem( + text = "Share", + leadingIcon = Iconsax.IconsaxShare, + onClick = { + lastAction = "Share" + expanded = false + }, + ) + DropdownMenuItem( + text = "Remove from playlist", + leadingIcon = Iconsax.IconsaxTrash, + enabled = false, + onClick = {}, + ) + } + } + + Text( + text = "Last action: $lastAction", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt index 4969bc4b..6111f2a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt @@ -25,20 +25,31 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic @Composable fun ListRowTile( @@ -88,7 +99,13 @@ fun ListRowTile( val mod = modifier .fillMaxWidth() - .shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor) + .shadow( + shadowDef.elevation, + shape, + shadowDef.clip, + shadowDef.ambientColor, + shadowDef.spotColor + ) .clip(shape) .border(borderDef.width, borderDef.color, shape) .background(background, shape) @@ -130,3 +147,94 @@ fun ListRowTile( } } } + +private data class ListRowPreviewTrack( + val title: String, + val subtitle: String, + val duration: String, +) + +private val ListRowPreviewTracks = listOf( + ListRowPreviewTrack("Midnight City", "M83 · Hurry Up, We're Dreaming", "4:03"), + ListRowPreviewTrack("Odoriko", "Vaundy · strobo", "3:37"), + ListRowPreviewTrack("Instant Crush", "Daft Punk · Random Access Memories", "5:37"), +) + +@BaseUIPhonePreview +@Composable +private fun ListRowTilePreview() { + BaseUIPreview { + var selectedIndex by remember { mutableStateOf(0) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + ListRowPreviewTracks.forEachIndexed { index, track -> + ListRowTile( + selected = selectedIndex == index, + onClick = { selectedIndex = index }, + leading = { ListRowTilePreviewArtwork() }, + title = { + Text( + text = track.title, + style = MaterialTheme.typography.bodyMedium, + ) + }, + subtitle = { + Text( + text = track.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Text( + text = track.duration, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + + ListRowTile( + enabled = false, + title = { + Text( + text = "Add to queue", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + }, + subtitle = { + Text( + text = "Unavailable offline", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + ) + }, + ) + } + } +} + +@Composable +private fun ListRowTilePreviewArtwork() { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusic, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt index 5007db43..0a159766 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -38,9 +39,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,7 +50,6 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp private val RadioSize = 22.dp @@ -145,33 +146,38 @@ fun Radio( } } -@Preview +@BaseUIPhonePreview @Composable private fun RadioPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = true, onClick = {}) - Text("Selected", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = false, onClick = {}) - Text("Unselected", style = MaterialTheme.typography.bodyMedium) - } + BaseUIPreview { + val options = listOf("Automatic", "High", "Lossless", "Data saver") + var selected by remember { mutableStateOf(0) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + options.forEachIndexed { index, label -> + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Radio( + selected = selected == index, + onClick = { selected = index }, + ) + Text(label, style = MaterialTheme.typography.bodyMedium) } } + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Radio(selected = true, onClick = {}, enabled = false) + Text("Disabled", style = MaterialTheme.typography.bodyMedium) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt index 63de1990..2d059068 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt @@ -30,6 +30,8 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -40,10 +42,8 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -61,7 +61,6 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlin.math.roundToInt @@ -311,3 +310,60 @@ fun Slider( ) } } + +@BaseUIPhonePreview +@Composable +private fun SliderPreview() { + BaseUIPreview { + var volume by remember { mutableFloatStateOf(0.65f) } + var progress by remember { mutableFloatStateOf(0.3f) } + var quality by remember { mutableFloatStateOf(2f) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), + ) { + SliderPreviewField(title = "Volume", value = "${(volume * 100).toInt()}%") { + Slider(value = volume, onValueChange = { volume = it }) + } + SliderPreviewField(title = "Progress", value = "${(progress * 100).toInt()}%") { + Slider(value = progress, onValueChange = { progress = it }) + } + SliderPreviewField(title = "Quality", value = "${quality.toInt()} / 4") { + Slider( + value = quality, + onValueChange = { quality = it }, + valueRange = 0f..4f, + steps = 3, + ) + } + SliderPreviewField(title = "Disabled", value = "40%") { + Slider(value = 0.4f, onValueChange = {}, enabled = false) + } + } + } +} + +@Composable +private fun SliderPreviewField( + title: String, + value: String, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, style = MaterialTheme.typography.bodyMedium) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + content() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt index 0c016aba..0309f2a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt @@ -31,19 +31,26 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,11 +62,11 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import dev.krtirtho.spotube.resources.iconsax.User private val TextFieldMinHeight = 44.dp @@ -201,43 +208,84 @@ fun TextField( } } -@Preview +@BaseUIPhonePreview @Composable -fun TextFieldPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - TextField( - modifier = Modifier.padding(top = 4.dp), - value = "Twenty One Pilots", - onValueChange = {}, - placeholder = { Text("Placeholder") }, - label = { - Text( - "Search Field", - modifier = Modifier.padding(bottom = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - }, - leadingIcon = { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search Icon", - ) - }, - trailingIcon = { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Clear Icon", - ) - }, - ) - } +private fun TextFieldPreview() { + BaseUIPreview { + var email by remember { mutableStateOf("") } + var query by remember { mutableStateOf("Twenty One Pilots") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + TextField( + value = email, + onValueChange = { email = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("you@example.com") }, + label = { TextFieldPreviewLabel("Email address") }, + leadingIcon = { + Icon( + imageVector = Iconsax.User, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + singleLine = true, + ) + + TextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search songs, artists, albums...") }, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxSearchBroken, + contentDescription = "Search", + modifier = Modifier.size(18.dp), + ) + }, + trailingIcon = { + Icon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = "Clear", + modifier = Modifier.size(18.dp), + ) + }, + singleLine = true, + ) + + TextField( + value = "not-an-email", + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + label = { TextFieldPreviewLabel("Error state") }, + isError = true, + singleLine = true, + ) + + TextField( + value = "Read only value", + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + label = { TextFieldPreviewLabel("Disabled") }, + enabled = false, + singleLine = true, + ) } } } + +@Composable +private fun TextFieldPreviewLabel(text: String) { + Text( + text = text, + modifier = Modifier.padding(bottom = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt index 446a3fe9..ef3ddb83 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt @@ -31,6 +31,9 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -38,11 +41,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,7 +57,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max @@ -239,26 +242,56 @@ fun Toggle( } } -@Preview(showBackground = true) +@BaseUIPhonePreview @Composable private fun TogglePreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Toggle(checked = true, onCheckedChange = {}) - Toggle(checked = false, onCheckedChange = {}) - Toggle(checked = true, onCheckedChange = null) - Toggle(checked = false, onCheckedChange = null, enabled = false) - } - } + BaseUIPreview { + var scrobbling by remember { mutableStateOf(true) } + var offlineMode by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + TogglePreviewRow( + label = "Scrobble listened tracks", + checked = scrobbling, + onCheckedChange = { scrobbling = it }, + ) + TogglePreviewRow( + label = "Offline mode", + checked = offlineMode, + onCheckedChange = { offlineMode = it }, + ) + TogglePreviewRow( + label = "Discord rich presence", + checked = true, + enabled = false, + onCheckedChange = {}, + ) } } } + +@Composable +private fun TogglePreviewRow( + label: String, + checked: Boolean, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Toggle( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt index fc8ce272..19e986eb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt @@ -1,9 +1,26 @@ package dev.krtirtho.spotube.core.ui.base +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp fun Modifier.highlight(color: Color): Modifier = drawWithCache { @@ -21,3 +38,39 @@ fun Modifier.highlight(color: Color): Modifier = ) } } + +@BaseUIPhonePreview +@Composable +private fun HighlightModifierPreview() { + BaseUIPreview { + var highlighted by remember { mutableStateOf(true) } + + Row( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(96.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .then( + if (highlighted) { + Modifier.highlight(Color.White.copy(alpha = 0.45f)) + } else { + Modifier + }, + ) + .clickable { highlighted = !highlighted }, + ) + Box( + modifier = Modifier + .size(96.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + ) + } + } +} From fa9ad9981c6f4f2356dc1c00bfee63424988fb10 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 7 Oct 2026 14:07:09 +0600 Subject: [PATCH 033/132] feat(settings): add 'About' section and improve header visibility in settings sections --- assets/images/album-placeholder.png | Bin 95911 -> 0 bytes assets/images/bengali-patterns-bg.jpg | Bin 291893 -> 0 bytes assets/images/logos/dab-music.png | Bin 3703 -> 0 bytes assets/images/logos/invidious.jpg | Bin 2502 -> 0 bytes assets/images/logos/jiosaavn.png | Bin 14045 -> 0 bytes assets/images/placeholder.png | Bin 8886 -> 0 bytes assets/images/user-placeholder.png | Bin 48389 -> 0 bytes .../drawable/plugin_logo_lrclib.png | Bin 0 -> 5787 bytes .../drawable/plugin_logo_newpipe.svg | 17 + .../composeResources/values/strings.xml | 1 + .../spotube/core/ui/base/DropdownMenu.kt | 41 +- .../component/AdaptiveDropdownBottomSheet.kt | 64 +-- .../spotube/core/ui/component/Sidebar.kt | 142 ++++++ .../modules/blacklist/BlacklistScreen.kt | 180 ++++---- .../spotube/modules/plugin/BuiltInPlugins.kt | 17 + .../spotube/modules/plugin/PluginScreen.kt | 189 +++----- .../spotube/modules/plugin/PluginViewModel.kt | 13 +- .../components/DefaultAbilityPluginGrid.kt | 420 ++++++++++++++++++ .../modules/plugin/components/PluginCard.kt | 53 +-- .../modules/plugin/components/PluginLogo.kt | 76 ++++ .../components/PluginPermissionsDialog.kt | 36 +- .../modules/settings/JamSettingsScreen.kt | 404 +++++++++-------- .../modules/settings/SettingsScreen.kt | 165 ++++--- .../modules/settings/SettingsSidebarLayout.kt | 356 +++++++++++++++ .../settings/sections/AppearanceSection.kt | 5 +- .../modules/settings/sections/CacheSection.kt | 5 +- .../settings/sections/DesktopSection.kt | 5 +- .../settings/sections/DownloadsSection.kt | 5 +- .../sections/LanguageRegionSection.kt | 5 +- .../settings/sections/PlaybackSection.kt | 314 ++++++------- .../settings/sections/UpdatesSection.kt | 5 +- .../modules/settings/sections/utils.kt | 18 + .../spotube/modules/shell/AppSidebar.kt | 114 +---- 33 files changed, 1826 insertions(+), 824 deletions(-) delete mode 100644 assets/images/album-placeholder.png delete mode 100644 assets/images/bengali-patterns-bg.jpg delete mode 100644 assets/images/logos/dab-music.png delete mode 100644 assets/images/logos/invidious.jpg delete mode 100644 assets/images/logos/jiosaavn.png delete mode 100644 assets/images/placeholder.png delete mode 100644 assets/images/user-placeholder.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt diff --git a/assets/images/album-placeholder.png b/assets/images/album-placeholder.png deleted file mode 100644 index bcb25969b641d9157b78be510af39555966941b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 95911 zcmV)+K#0GIP)00Hy}0ssI2M6^iV00009a7bBm000XU z000XU0RWnu7ytkYPiaF#P*7-ZbZ>KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z00C>#Nkl5pYub`^dy-iyegvc{~+%9^XoUG1g^TePtOd1%X)8w^4=!hjJV zfdpcb_+$7Z_`pY?0bzqNw(Pd8pJ#Ww>2`I^b5>T4F};ZVeK^&pcC6{%_adw9{7}k@ zeDU6W_nx!Q-fOSD)|tQm_y7LE!9i=SwRYyrnY(xI_P^Ah+`W6Z|7B}ytN(BR%Xm#Y z`DeVo|5JZu{gv^W@w@fK@ac=i;`sQu{=8n;*49@2d4JjQ@$q=Kt*xy#?+<(g{BQke z`Rnyw!HfAzh0Cb7PW~Bp67J!+?Eac@6W~knSKyD67YVQFHx^!2e{psNQt1b&zXYe)0JB+U>w@27Bu-SN_*Y~fVE?3of9Aneg$Tglwy#xJlJvusqBdz}~ z>^FCN;m*LW9?ucp+zIB`c*1tf&9rukR1&4robNbOT4wxEA$J*f10k#$#67_ST z^n@q_A&C##derr$*#9zK!?CZ%eYh{t6DZg@?<*P9_9yVau??_gp?Qs4DoK8K?~CTn;-mU4#7|!VV;eIqdY{J|g=^ z`vrm>mxr)!U;&L?2TADo8hkc5EKL5rUe%lfKyG#367`2UoWai_Z{;uq32zNd{1tes zk2_g+^|-^XDFT3u#K$28gLji@AeYq=C}Thj%3+DNI|vSHEKarfDZrc8OBxr+9fJB? z#zkB_a`uHG**683KZat9PvzDcA%cAIDL>6O{RIsigIR0UM7tE=`{w)lM^l#cDp?G88VjV*?6e^kAs-F>d!r^ zu}k3*!v)vH#Bs1TlmdXp0A1bTfp!8LwW2YQIFA@41Mc{g9#CX)!^Nnq)@!_T4~xq( z2?mDh&@F74zPQWgBBQ3#wUTrDl%YI`jF1$=&71^pC6LI8RH!GQ(sgl`Q&L8^#{Z5d zTN4qcZ^-<~1s2HdYKwJd*pYx<{q@yq)pK?Mf-&eL`2+Bga8K)TF?N(Z#Nc#=E$50R znbh#*$>kmZJDK3SdW|tVMyO<|Uh!hCJmlu*;?5NxyqP736*|DOrl}{tBXR)44@d=* z4V9X-a>VE3q+&aOEJ6Dbz#PV8ID`RWBb$ftiKF8xt2%k-C0T_Jdx^1x-kQU;Bor<) z`mmNbr;(Q}0hU?-ajS`On9#cCUm0rvDnCw&nacoSM;=kklaX5zUJ&{&j9=xa3_YIw z4#&sGM@L8d`}_3-s^Mtyna_M?Z*NcH1JD(5Z3I`=WwgYhgf90CSfR_NXYx#C`7Enx zSSaHho`jX;K4+d8)P0M^A~cQpSe5yBIQC_k2@8PnpK;l)uUK;*c_qN`lW7zb5CC6r zYmTcchjPeHBjfcWjxdD6MTuML07l8>%{5=Be&n*8EI9ZI9RU5%huSZ%eag1g=SX|Y&9_pCyYKtXZsAPE{yvf^YS$(au4H4bo-$#tOg@q-&2 z`J7>euPJX7JX5}~kg@<-R#tWOhs=o#4kF!{IZop~UwbK9{p&iJ=4AN77CJb&)s~!%z~h zlo=31r4^`|WF0uVr0d8rtWg{3OuJ%a7RDwdEL#ce@KzF30hChqX&ulf;ab<$<~su< zUD8#iZ?a=jGb{_Y5hg-s0%emd?+U)lO#(MT(hM9CGqlGi zr}N#fxHqq4IM;J-G}tBImLZgiHdJ6ucHJFSiT9u+yw_#xwUk_rqjn-g}pcePg7 z0e<#m*n#aPp+caCx~ye>*r_>$3_Bf==|H+%(jZeUIV3VD zD6*+AXV5I5TXGiPJwa|GOGTGl={p^ms@&jcwfSbkQ{&hqJA3uU0zh2*Y!c~)iPo%X z<5s?eE*Wrx0K~w8Og!Ob>xLaZ>gI$U&XfbTDf!KuNY!W2{G6s7zu+^Y)@?HE^qhTn zcejQe*R5%-ZG|aw7>eZ(CfVFISVPpVIbA)vrG!P8r_anY7w!X#2Fks0?ER&kx7#;`YWZ03_l57fct^llxuoNAyAC3wab}AYJ(>6Q-JFacZbt>GT z2hW`{93~kKOxb~T@9OADZL*n7m@rlEAOa5SJWv3wm^^g?haa%Gk_ba3T=g_R*D5FdTyMI0=>iwi0dEOII1Evrl*y<;>j$k zR>@FCaG5C6Mu&hq$=N{OXGsRmZQ%%)N~nB$+mz1CoUk+C!HvTXA8ED1;Qi$|$Fk=y z(I**p;7!JuQdjZiD#6YDecTS=km`gNBz+aOr5wfy#3PQvP6Nvw&Jyy=FpdldOrEXu z72fmn<##GH#`@82AixdF%;AARJFY1`igr?m%QXX?47a1o!NkfW;hm8{H&n%(lduiKU7)LDSIR$z2^)JqG6W%0LLo%BNP?A413oQWCQI7D) zzTzbJKd{2(j&;ijRz*nFgC+^+I*mO%_&+Rr0M!_X9q~GpeFzS7^3FRsFnMrF=D4d+ z$Az)hf@|;d{*pnhz5_FSSn16DWt<$mk}F733Gc7~;8Bih71joi!pg8C4{S+<#-pqIqF2AT`P7&LrqieabZ^txlSk-+`RX`cZqHEC$+_-B%52GKmI#RmCLNi&6fLQW&|_pSFCKztry%|56+ zZ&~XY&Ng6i@{!C{9R$$%f@O)oCUKxcT4NY?zY<5&Q)%3mw&Fb)aUPIBrsgdI8H zHW_yM%SL2hE9_uO(Tu4Aw;;GXAIJ&Nkzgi+#5$xXF4y~VK$I;7BpeUz(BLELh}$rN zQPoNQq5D59iauN8KBs@L_|UD%Q1xczj^oy#=YAmRapKvk4}U2@vZ5pTo%cboD@mYiit6q)oOW{x{Qs4&KY%;#TxS>q-rCF#TG5l9NpecW;rpFEYX~A4%{VY$H78?a@b0iz>_1; zP01Cwg#oGnp={^q24gtxMs-eJo*rQCwlghYH47&Xkcapu4M}8QVXd&^ez;lK;pUCh zXbOYnYlWTT<73c`;f*=R`uxn9GuzwSTU|qn<-0{8!NJAx6eubxDyALg59-j&w94< zTn|Huu=ZrZ3;CUdJDm@`PV~TYBgnnVB+i)^J3imgr3w}>q0s@8;vCML?7i%t$au|zd9u~kjNEziQnZh7yCWiAWVUpBZ+VnepBr?1Umrh8szoefs=z9mkICqoT$W+QSG&XL`jsDxWIScV!cPprFBS{eH0BUftJiwN zrHQ-CyMw1vFqr1gWg?a|+;F@}=`O${o!!K$P06vbK4?&mW4x8KT?s+B^j}Irg!(De z9L>xsyI?96IAqviEzil>2yil3H|#X!kIDd=6jzYK2{P+Z7HU0Jj9kpL1lP&{PN@JMUOn573Kq9-Dzf0@;0~OFJ(uo_uLh zMGg*r*0YrNIYj4XR7d7FHYVvnGm)9*t!daLb*-?&b7A)+>@=DeldBFw?FM0o6UvIl zKo?;$n1p@Qzx9@+*+Z2IlUk#ckaRaEVLYfKJd_W&kEel~v1|=}MTy=>Qn0MELI{>z z?9iPHq*OD+@@JUJiK!(fC?4giyT5qyLz^N$E z&s|G{IXM}lhqraJ#2iid#>#qnCKl|{3r9Q)Z8v@O(N7}mxXHL2 zb~qd#9Ua}dbH@o%=CI>@_-Zn@6^IsjBh?uJNt)z@5gLivN@DDpDl-i&zE;NJc$$gR zPbdy#Kgx~dF%b=b6see6MeO)@rPqYRnmLhoYIb2n0wx%$sGbDD!?ckkzsqAO7kQIY zLj1Wr1L?W~QtnN5ecMUlUpZ6(<4$h87G_Vv(awsSPK{mqT*K_;P^O#gBE(BGLs?ldW;*K6j z))wofNad3NQcp(wK&f<$@@&2;4X}bmH8A2i>^NqWQ|wtc?67E04mPRX&1-su5v{-; zaV__t0o#mXmxL>sM(ns!^)=OUQ_{)jJU_|c zhU~HiY6_#Y86uF9t?Zx4M1as!l^M6AW-$%}dLzv!vjZRo9BNpK%&?Z-Bk5-{iOe{_ zB(N<5+&qV`DU+C)VW*K;zbr}D4Ldwg?ZS=(VWc!VgqJ;XmL3FN$EZlt#OktT^GZzh*>CO0KCi%@dMH) zgAq(iaRyXZEwl#)M#0>#0SyhRU#`vP6Ehra?Z^^ta2NW?Y((V-QaGXG$05(KQ2n^$ zLHf|T%Ak^Pg33*pIFVyJxi)fd>j=;p)d_HXFy@^fcAD2={T_rJI45R?o%UVljm)^` z9FA4z`25zk8O`f`OLaZzc+iX&LBR4@*fTWfYxfWGBL;=RVZC`1Mb5>fw3}l!f!wQ; zjF9dydBB=;Oc29@5a0%{oTwhd2&_85Y~0EF$<9Y}=rJLqlmg`v;gNCBrHbyrV4zb@ zSB84ro#nycsU~{7MaPR-Pq8Tnt~pakllY8W!}$esBpE`^dz!E#o9#hG2VR^BouGT; zTsc`)x6ypum{gL*v`fw=3y2WCw>T4-qq`zD9Z<-*QT@nv?L^l6$IcH|Y-d$u%^Db>l1#Q(}C`+PG#>NH&^Y5A6y%`51RafAh;-9iX4DYmsBfk+d zWg!=1eg$s9k(=*V22PG3u+vkv5u#0JT5;7Bc%8+d(JxtMA`$u{SWtlB_pNj@pb0F$&lI5W5rBzsv7Cz(I_(x!A* zq_o~-gU-pbxN3kWO~M#XIHDT)A~F08dfLs*HuGW#cepDqWhzZEA&HS7d&Bo}rdvP*WO3_Gr_b%p^h>?~e<@x?oL?r_n~8riVV z0XhV>wIdfpGup-Cyr7*lC@`g1<)hw89>e_N>pgaZn=$6D#7-Dn? z6_C>d-jZu`h6^+IKWAs5(&949%@)Xs$4S2d9(0fw=6YQ6qxP=fos2h$C84jxEj{gM)skd>w~ZPG`s&gk~Ht+-;d;ygJ3fY@N=N zVojNQWCb)f#0f%aF1_XQr71yU{$5hOGCUwB*_b^RLM|y+qjW*yHO8gAJCk_nLKt%6 zV=nb$>aEP6UAw*+i)UFSOW_drvfNOW0;`fI28HPymQ)BklS)tafQf-4KK%$rSii9F)SElWyuG(yBH6xC`xck2yi*egKz^@ zY7;hZSjs1dT(K-U0uA|^9 zS$7D-xKybo(M~Gf$UeL43_CK0^qr`w85rJ=eG3YoH9#{57Pg18vTVlLqsCbn25~$# zCeQH%P$NShC;1?6P9;!{$rQM^Qv9-Fogg6PvS2wTF7>mhQ_3U!RyJ(KLyBjoT$~m8lj*0`+Vdp)|QFyF}%E*NDob}&BThbxC{GhXK zmV*Rc%;jHaU6;xs0cDk$VaF9}P%%t~9UcbETVt>u>sJG^rer#Vup?)+npZOaD4lZP zl(YAAMSZ3*adOJ3sB3V^Q5r`O=2S>3dCH<2mXSQwc>6(Y<+70*61DAITfXcbAS*|g zq_82i#7yK3nVck&;Hh)k1Z50q<|R`z&Q=Bk(ur#}QunM`3~vK|UT^@1u~H6Fo%mbN zCn(QdLRxPWNLEF048DdVyQr%#eL;3bTfC^8?V5^A{Q~ z8G(g?{z_K>IYxl9107JNFg3kmGF;X+f!$sK4Jj&8*Qm6-ska&?m0W)6+N#dnN{-TS zWhM=vrHF-G_l!|U0*@qF0)7QYk(_jIWF{_aI7YOBtEW>Tu4!x_$V=}x*U9I)4Q2r4 zfjS4hbb~WeHF^V?nweoI%QJ9Oq{hCMH5VELLBVC_m@I+$w~+OfEIHNpps0ZUnJT6|dEr=E{aS zluiP7RE3T!R;AlzHVPWnJjWp$_W_M%B)xuU#o45kqnzgs;$qxBZN@1APRH8Ik^Kpo zx=QUZz?9^ej0AQhk->F1iwzzB0vcn{wM(m&+QY{8X+_;u}Ue$5C`T0_4 zgjN)Sr)NXaCKJhVR6y%k^3^0ES~fF6De1~n*RhB32sa1dIDC>R(7ZSjd>0UKmbm$( zau#P-!C*&fD@`WsQjj$dH$d2d6x)RzFwWd8?6~x$^4Zzmmcx!)WA`fToCxmHI1~o@AU!9je@ z;d&Bhl;c+pos+t$SwWD6b}O{)+LbP84VGrIkHTG6)*gYQg%v>Mh)^?54&2m{)Qjd# zXywRaQ|8WDfQ1{;eA75s%aQUqSjwEaC+E)S^6tsrbvvmK1NYylA)O}d1Uhoh*63(+G^divokQnY?w%F^sZudbHVK;_wV$(O zWn_difEB)aZX)S`>;_qX&Z|VOw!quvE9a3^P9j*vg?oB(-!hd@GHUYvmx6ht&XDM{ zQz)$YFEbhB7@9o1LCzryHX-0KpupWa(^u}Q)(Er_iK9NyL4km+El9d6i31Bw^ZA)f7u$Z{N$LpKX z7`dzY00j6&cJ?@(91dzcvziyK)Q^>kQZvRL#DO{goO z9s@2PuRQ?nD9WL>z(eKqke44{UJhYW`Et_pFQ{J2aNmq^4+~&4W2h^ibroBcgk}Z- zadnsRJbeFX@0PQ!`hjgM~=to;&MmuC2I50c$iU zg}P}DU=vG{EKDbGtFWpV@*y4kh9vz_nuocMXi7dzHiYU~<*>oZ*J3*q2|Y~?j*dIb zbWG+lJK&x}KQr)TTTf>C62zCI|C94Ilf>vOu@H_K$$E6e#L2J&sw6w{?{jo4iZ18AVrV}j
@Tgt>${VurLnP*oj85b{omgmgp$f*q83%D=pbuY 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 P0{}H8EoilZMcDrVV@Bk$ literal 0 HcmV?d00001 diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg new file mode 100644 index 00000000..7cb4554d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b636db4f..75571344 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -26,6 +26,7 @@ Caching Desktop Updates + About Close Save diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt index 63dc38ec..20f9af7f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt @@ -106,6 +106,8 @@ fun DropdownMenuItem( modifier: Modifier = Modifier, enabled: Boolean = true, leadingIcon: ImageVector? = null, + leadingContent: (@Composable () -> Unit)? = null, + subtitle: String? = null, selected: Boolean = false, theme: BaseUITheme.DropdownMenuTheme? = null, ) { @@ -142,8 +144,9 @@ fun DropdownMenuItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - if (leadingIcon != null) { - Icon( + when { + leadingContent != null -> leadingContent() + leadingIcon != null -> Icon( imageVector = leadingIcon, contentDescription = null, modifier = Modifier.size(18.dp), @@ -155,18 +158,28 @@ fun DropdownMenuItem( ) } - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = if (enabled) { - menuTheme.itemForeground - } else { - menuTheme.itemForeground.copy(alpha = 0.38f) - }, - modifier = Modifier.weight(1f), - maxLines = 1, - softWrap = false, - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) { + menuTheme.itemForeground + } else { + menuTheme.itemForeground.copy(alpha = 0.38f) + }, + maxLines = 1, + softWrap = false, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = menuTheme.itemForeground.copy(alpha = if (enabled) 0.6f else 0.38f), + maxLines = 1, + softWrap = false, + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index b87433f7..cc1068aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -82,6 +82,8 @@ data class AdaptiveMenuItem( val enabled: Boolean = true, val selected: Boolean = false, val dividerBefore: Boolean = false, + val subtitle: String? = null, + val leadingContent: (@Composable () -> Unit)? = null, ) enum class HeaderDisplayMode { @@ -156,6 +158,8 @@ fun AdaptiveDropdownBottomSheet( enabled = item.enabled, selected = item.selected, leadingIcon = item.icon, + leadingContent = item.leadingContent, + subtitle = item.subtitle, ) } } @@ -261,33 +265,32 @@ private fun AdaptiveBottomSheetContent( ) .padding(horizontal = 12.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (item.icon != null) { - Icon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } else if (item.selected) { - Icon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else if (hasSelection) { - Spacer(modifier = Modifier.size(22.dp)) - } + when { + item.leadingContent != null -> item.leadingContent.invoke() + item.icon != null -> Icon( + imageVector = item.icon, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = if (item.enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + + item.selected -> Icon( + imageVector = FeatherIcons.Check, + contentDescription = "Selected", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + hasSelection -> Spacer(modifier = Modifier.size(22.dp)) + } + + Column(modifier = Modifier.weight(1f)) { Text( text = item.label, style = MaterialTheme.typography.bodyLarge, @@ -297,9 +300,16 @@ private fun AdaptiveBottomSheetContent( MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) }, ) + item.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - if (item.selected && item.icon != null) { + if (item.selected && (item.icon != null || item.leadingContent != null)) { Icon( imageVector = FeatherIcons.Check, contentDescription = "Selected", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt new file mode 100644 index 00000000..aae13619 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt @@ -0,0 +1,142 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.SecondaryButton +import dev.krtirtho.spotube.core.ui.base.copyPadding +import dev.krtirtho.spotube.core.ui.base.copyShape + +/** + * The shared navigation item used by every sidebar in the app (the main app sidebar + * and the settings sidebar), so they stay visually identical. + * + * @param icon leading icon of the item. + * @param badge optional overlay drawn on the icon (for example a download indicator). + */ +@Composable +fun SidebarItem( + label: String, + icon: ImageVector, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + expanded: Boolean = true, + badge: (@Composable BoxScope.() -> Unit)? = null, +) { + val itemContent: @Composable RowScope.() -> Unit = { + Box(modifier = Modifier.size(16.dp)) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + badge?.invoke(this) + } + AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { + Text( + text = label, + maxLines = 1, + softWrap = false, + style = LocalTextStyle.current.copy( + fontSize = MaterialTheme.typography.labelMedium.fontSize, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + ) + } + Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) + } + + val buttonModifier = modifier + .fillMaxWidth() + .padding(horizontal = 7.dp, vertical = 2.dp) + val contentPadding = PaddingValues(horizontal = 5.dp) + + if (selected) { + SecondaryButton( + onClick = onClick, + modifier = buttonModifier, + theme = LocalBaseUITheme.current.buttons.secondary + .copyPadding(contentPadding) + .copyShape(RoundedCornerShape(5.dp)), + content = itemContent, + ) + } else { + OutlineButton( + onClick = onClick, + modifier = buttonModifier, + theme = LocalBaseUITheme.current.buttons.outline + .copyPadding(contentPadding) + .copyShape(RoundedCornerShape(5.dp)), + hoverOnly = true, + content = itemContent, + ) + } +} + +/** + * The shared section heading used above groups of [SidebarItem]s. + */ +@Composable +fun SidebarSectionLabel( + text: String, + expanded: Boolean = true, +) { + AnimatedVisibility(visible = expanded) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt index 087e02ec..8aad2670 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt @@ -67,6 +67,30 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun BlacklistScreen() { val viewModel = koinViewModel() + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Blacklist") }, + ) + }, + ) { innerPadding -> + BlacklistContent( + viewModel = viewModel, + modifier = Modifier.padding(innerPadding), + ) + } +} + +/** + * The blacklist UI without its own [Scaffold] / app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen]. + */ +@Composable +internal fun BlacklistContent( + viewModel: BlacklistViewModel, + modifier: Modifier = Modifier, +) { val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle() val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current @@ -102,92 +126,82 @@ fun BlacklistScreen() { val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty() val hasActiveFilter = normalizedQuery.isNotBlank() - Scaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = { Text("Blacklist") }, - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextField( - value = filterQuery, - onValueChange = { filterQuery = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Filter blacklist...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextField( + value = filterQuery, + onValueChange = { filterQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Filter blacklist...") }, + singleLine = true, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search", + ) + }, + ) - if (isEmpty) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center, - ) { - Text( - text = if (hasActiveFilter) { - "No blacklisted items match '$filterQuery'" - } else { - "No blacklisted items" - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - bottom = 16.dp + shellBottomInset, - ), - ) { - if (filteredTracks.isNotEmpty()) { - item { - Text( - text = "Tracks", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - items(filteredTracks, key = { "track_${it.id}" }) { track -> - BlacklistedTrackRow( - track = track, - onRemove = { viewModel.toggleTrack(track) }, - ) - } + if (isEmpty) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (hasActiveFilter) { + "No blacklisted items match '$filterQuery'" + } else { + "No blacklisted items" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + bottom = 16.dp + shellBottomInset, + ), + ) { + if (filteredTracks.isNotEmpty()) { + item { + Text( + text = "Tracks", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) } + items(filteredTracks, key = { "track_${it.id}" }) { track -> + BlacklistedTrackRow( + track = track, + onRemove = { viewModel.toggleTrack(track) }, + ) + } + } - if (filteredArtists.isNotEmpty()) { - item { - Text( - text = "Artists", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - items(filteredArtists, key = { "artist_${it.id}" }) { artist -> - BlacklistedArtistRow( - artist = artist, - onRemove = { viewModel.toggleArtist(artist) }, - ) - } + if (filteredArtists.isNotEmpty()) { + item { + Text( + text = "Artists", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + items(filteredArtists, key = { "artist_${it.id}" }) { artist -> + BlacklistedArtistRow( + artist = artist, + onRemove = { viewModel.toggleArtist(artist) }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt index 9a642cd6..b43b5c52 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt @@ -17,6 +17,11 @@ package dev.krtirtho.spotube.modules.plugin +import org.jetbrains.compose.resources.DrawableResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_logo_lrclib +import spotube.composeapp.generated.resources.plugin_logo_newpipe + val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry( name = "NewPipe YouTube", version = "0.1.0", @@ -52,3 +57,15 @@ val BUILT_IN_PLUGINS = listOf( NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, LRCLIB_BUILT_IN_PLUGIN, ) + +/** + * The bundled placeholder logo for a built-in plugin, or `null` if it has none. + * + * Built-in plugins are not installed to disk, so they have no `logo.png`; these + * resources (in `composeResources/drawable`) stand in as their logos. + */ +fun builtInPluginLogo(plugin: PluginEntry): DrawableResource? = when (plugin.id) { + NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_newpipe + LRCLIB_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_lrclib + else -> null +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index ede1fb60..8fcd5baf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -75,9 +75,14 @@ import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid import dev.krtirtho.spotube.modules.plugin.components.PluginCard import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog +import dev.krtirtho.spotube.modules.plugin.components.PluginLogo import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog +import dev.krtirtho.spotube.modules.plugin.components.abilityIcon +import dev.krtirtho.spotube.modules.plugin.components.accentColor +import dev.krtirtho.spotube.modules.plugin.components.displayLabel import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.openUrlInBrowser import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo @@ -86,16 +91,12 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxBox import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk import dev.krtirtho.spotube.resources.iconsax.IconsaxLink -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxSound -import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import okio.Path @@ -127,10 +128,6 @@ import spotube.composeapp.generated.resources.plugin_section_file_title import spotube.composeapp.generated.resources.plugin_section_install import spotube.composeapp.generated.resources.plugin_section_url_title import spotube.composeapp.generated.resources.plugin_url_placeholder -import spotube.composeapp.generated.resources.settings_plugins_ability_audio -import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics -import spotube.composeapp.generated.resources.settings_plugins_ability_metadata -import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble import spotube.composeapp.generated.resources.settings_plugins_action_change import spotube.composeapp.generated.resources.settings_plugins_action_select import spotube.composeapp.generated.resources.settings_plugins_default_ability_title @@ -147,6 +144,32 @@ private val VERIFIED_PLUGIN_OWNERS = setOf() fun PluginScreen( viewModel: PluginViewModel = koinViewModel(), onboarding: Boolean = false, +) { + Scaffold( + topBar = { + if (!onboarding) { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } + }, + containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface, + ) { innerPadding -> + PluginScreenContent( + viewModel = viewModel, + onboarding = onboarding, + modifier = Modifier.padding(innerPadding), + ) + } +} + +/** + * The plugin management UI without its own [Scaffold] / app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [PluginScreen]. + */ +@Composable +internal fun PluginScreenContent( + viewModel: PluginViewModel, + onboarding: Boolean = false, + modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current @@ -222,28 +245,17 @@ fun PluginScreen( } } - Scaffold( - topBar = { - if (!onboarding) { - ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) - } - }, - containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface, - ) { innerPadding -> + Box(modifier = modifier.fillMaxSize()) { when (val data = uiState) { is PluginUiState.Loading -> { Box( - modifier = Modifier.fillMaxSize().padding(innerPadding), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } is PluginUiState.Data -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { + Box(modifier = Modifier.fillMaxSize()) { val discoverListState = rememberLazyListState() LazyColumn( state = discoverListState, @@ -302,6 +314,19 @@ fun PluginScreen( } } if (!onboarding || !data.onboardingDiscover) { + + item { + DefaultAbilityPluginGrid( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + selections = data.abilitySelections, + onSelected = { ability, plugin -> + viewModel.selectPlugin(ability, plugin) + }, + ) + } + item { Row( modifier = Modifier @@ -325,39 +350,6 @@ fun PluginScreen( } } - item { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 4.dp) - ) { - data.abilitySelections.forEachIndexed { index, selection -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) - } - DefaultAbilityPluginSelector( - selection = selection, - onSelected = { plugin -> - viewModel.selectPlugin( - selection.ability, - plugin - ) - }, - ) - } - } - } - } - if (data.plugins.isEmpty()) { item { Box( @@ -721,26 +713,22 @@ private fun PluginInfoDialog( .clip(RoundedCornerShape(12.dp)), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) ) { - if (logoPath != null) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(logoPath.toString()) - .crossfade(true) - .build(), - contentDescription = plugin.name, - modifier = Modifier.fillMaxSize() - ) - } else { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Box(contentAlignment = Alignment.Center) { + Icon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + }, + ) } Text( plugin.name, @@ -1135,31 +1123,16 @@ fun DefaultAbilityPluginSelector( ) { Surface( modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } + color = selection.ability.accentColor().copy(alpha = 0.1f) ) { Icon( - imageVector = when (selection.ability) { - PluginAbility.METADATA -> Iconsax.IconsaxDocumentText - PluginAbility.AUDIO -> Iconsax.IconsaxMusic - PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft - PluginAbility.SCROBBLE -> Iconsax.IconsaxSound - }, + imageVector = selection.ability.abilityIcon(), contentDescription = stringResource( Res.string.settings_plugins_plugin_content_description, selection.ability.displayLabel() ), modifier = Modifier.padding(8.dp), - tint = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } + tint = selection.ability.accentColor() ) } @@ -1203,28 +1176,13 @@ fun DefaultAbilityPluginSelector( ) { Surface( modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } + color = selection.ability.accentColor().copy(alpha = 0.1f) ) { Icon( - imageVector = when (selection.ability) { - PluginAbility.METADATA -> Iconsax.IconsaxDocumentText - PluginAbility.AUDIO -> Iconsax.IconsaxMusic - PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft - PluginAbility.SCROBBLE -> Iconsax.IconsaxSound - }, + imageVector = selection.ability.abilityIcon(), contentDescription = null, modifier = Modifier.padding(8.dp), - tint = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } + tint = selection.ability.accentColor() ) } Column(modifier = Modifier.weight(1f)) { @@ -1265,12 +1223,3 @@ fun DefaultAbilityPluginSelector( } } -@Composable -private fun PluginAbility.displayLabel(): String { - return when (this) { - PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) - PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) - PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) - PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt index 2f8917bb..4bc032b3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt @@ -63,6 +63,7 @@ data class AbilitySelection( val ability: PluginAbility, val plugins: List, val selectedPlugin: PluginEntry?, + val pluginLogoPaths: Map = emptyMap(), ) data class PluginDiscoverState( @@ -254,22 +255,26 @@ class PluginViewModel( AbilitySelection( PluginAbility.METADATA, snapshot.metadataPlugins, - state.selectedPlugins[PluginAbility.METADATA] + state.selectedPlugins[PluginAbility.METADATA], + logoPaths, ), AbilitySelection( PluginAbility.AUDIO, snapshot.audioPlugins, - state.selectedPlugins[PluginAbility.AUDIO] + state.selectedPlugins[PluginAbility.AUDIO], + logoPaths, ), AbilitySelection( PluginAbility.LYRICS, snapshot.lyricsPlugins, - state.selectedPlugins[PluginAbility.LYRICS] + state.selectedPlugins[PluginAbility.LYRICS], + logoPaths, ), AbilitySelection( PluginAbility.SCROBBLE, snapshot.scrobblePlugins, - state.selectedPlugins[PluginAbility.SCROBBLE] + state.selectedPlugins[PluginAbility.SCROBBLE], + logoPaths, ), ), pendingPlugin = snapshot.pendingPlugin, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt new file mode 100644 index 00000000..9f835868 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt @@ -0,0 +1,420 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.plugin.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem +import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode +import dev.krtirtho.spotube.modules.plugin.AbilitySelection +import dev.krtirtho.spotube.modules.plugin.PluginAbility +import dev.krtirtho.spotube.modules.plugin.PluginEntry +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxSound +import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft +import okio.Path +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_plugins_ability_audio +import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics +import spotube.composeapp.generated.resources.settings_plugins_ability_metadata +import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble +import spotube.composeapp.generated.resources.settings_plugins_default_ability_title +import spotube.composeapp.generated.resources.settings_plugins_no_plugins +import spotube.composeapp.generated.resources.settings_plugins_no_selection +import kotlin.random.Random + +/** The accent color used for a plugin ability across the whole plugin UI. */ +internal fun PluginAbility.accentColor(): Color = when (this) { + PluginAbility.METADATA -> Color(0xFF34A853) + PluginAbility.AUDIO -> Color(0xFF1E88E5) + PluginAbility.LYRICS -> Color(0xFFF9A825) + PluginAbility.SCROBBLE -> Color(0xFF8E24AA) +} + +/** The icon used to represent a plugin ability. */ +internal fun PluginAbility.abilityIcon(): ImageVector = when (this) { + PluginAbility.METADATA -> Iconsax.IconsaxDocumentText + PluginAbility.AUDIO -> Iconsax.IconsaxMusic + PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft + PluginAbility.SCROBBLE -> Iconsax.IconsaxSound +} + +/** + * A vibrant, multi-hue gradient per ability, used by the Frutiger-Aero plugin tiles. + */ +internal fun PluginAbility.aeroGradient(): List = when (this) { + PluginAbility.METADATA -> listOf(Color(0xFFA8E063), Color(0xFF56AB2F), Color(0xFF11998E)) + PluginAbility.AUDIO -> listOf(Color(0xFF56CCF2), Color(0xFF2F80ED), Color(0xFF6A11CB)) + PluginAbility.LYRICS -> listOf(Color(0xFFFFE259), Color(0xFFFFA751), Color(0xFFFF5E62)) + PluginAbility.SCROBBLE -> listOf(Color(0xFFFF6FD8), Color(0xFF9D50BB), Color(0xFF3F0D7A)) +} + +@Composable +internal fun PluginAbility.displayLabel(): String = when (this) { + PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) + PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) + PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) + PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) +} + +/** A short "v1.2.3 · Author" summary shown under a plugin in the picker. */ +private fun PluginEntry.metadataLabel(): String = buildString { + append("v").append(version) + if (author.isNotBlank()) { + append(" · ").append(author) + } +} + +/** + * The 2x2 grid of default-plugin selectors shown on large screens. Each tile is a + * Frutiger-Aero style card representing one ability and its currently selected plugin. + */ +@Composable +internal fun DefaultAbilityPluginGrid( + selections: List, + onSelected: (PluginAbility, PluginEntry?) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + selections.chunked(2).forEach { rowSelections -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowSelections.forEach { selection -> + DefaultAbilityPluginCard( + selection = selection, + onSelected = { onSelected(selection.ability, it) }, + modifier = Modifier.weight(1f), + ) + } + repeat(2 - rowSelections.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun DefaultAbilityPluginCard( + selection: AbilitySelection, + onSelected: (PluginEntry?) -> Unit, + modifier: Modifier = Modifier, +) { + val ability = selection.ability + val selected = selection.selectedPlugin + val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) + val noSelectionText = stringResource(Res.string.settings_plugins_no_selection) + + val menuItems = buildList { + if (selection.plugins.isNotEmpty()) { + selection.plugins.forEach { plugin -> + add( + AdaptiveMenuItem( + label = plugin.name, + subtitle = plugin.metadataLabel(), + leadingContent = { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + PluginLogo( + plugin = plugin, + logoPath = selection.pluginLogoPaths[plugin.id], + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Icon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = ability.accentColor(), + ) + }, + ) + } + }, + onClick = { onSelected(plugin) }, + selected = selected?.name == plugin.name, + ) + ) + } + } else { + add( + AdaptiveMenuItem( + label = noPluginsText, + onClick = { }, + enabled = false, + ) + ) + } + } + + AdaptiveDropdownBottomSheet( + items = menuItems, + modifier = modifier, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AbilityBadge(ability) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + ability.displayLabel(), + ), + style = MaterialTheme.typography.titleMedium, + ) + selected?.let { + Text( + it.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + }, + trigger = { onClick -> + AbilityPluginTile( + ability = ability, + selectedPlugin = selected, + logoPath = selection.pluginLogoPaths[selected?.id], + pluginName = selected?.name ?: noSelectionText, + version = selected?.version, + onClick = onClick, + ) + }, + ) +} + +@Composable +private fun AbilityPluginTile( + ability: PluginAbility, + selectedPlugin: PluginEntry?, + logoPath: Path?, + pluginName: String, + version: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val accent = ability.accentColor() + val gradient = ability.aeroGradient() + val shape = RoundedCornerShape(20.dp) + val seed = ability.ordinal + 1 + + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .clickable(onClick = onClick) + .heightIn(min = 148.dp) + .drawWithCache { + val background = Brush.linearGradient( + colors = gradient, + start = Offset.Zero, + end = Offset(size.width, size.height), + ) + onDrawBehind { + drawRect(background) + drawAeroDecorations(seed) + // Bottom scrim so the white text stays readable on light gradients. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.32f)), + startY = size.height * 0.3f, + endY = size.height, + ), + ) + // Glossy top highlight. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.35f), Color.Transparent), + startY = 0f, + endY = size.height * 0.55f, + ), + ) + } + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.35f)) + .border(1.dp, Color.White.copy(alpha = 0.6f), CircleShape), + contentAlignment = Alignment.Center, + ) { + PluginLogo( + plugin = selectedPlugin, + logoPath = logoPath, + contentDescription = pluginName, + modifier = Modifier.fillMaxSize(), + fallback = { + Icon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = accent, + ) + }, + ) + } + Icon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.White.copy(alpha = 0.85f), + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = pluginName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = buildString { + append(ability.displayLabel()) + if (!version.isNullOrBlank()) { + append(" · v").append(version) + } + }, + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun AbilityBadge(ability: PluginAbility) { + val accent = ability.accentColor() + Surface( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + color = accent.copy(alpha = 0.12f), + ) { + Icon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.padding(8.dp), + tint = accent, + ) + } +} + +/** + * Soft translucent bubbles and highlights behind a tile, giving the glossy + * Frutiger-Aero look. Deterministic per tile so it does not flicker on recomposition. + */ +private fun DrawScope.drawAeroDecorations(seed: Int) { + val random = Random(seed) + repeat(6) { + val center = Offset( + x = random.nextFloat() * size.width, + y = random.nextFloat() * size.height, + ) + val radius = size.minDimension * (0.12f + random.nextFloat() * 0.4f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent), + center = center, + radius = radius, + ), + radius = radius, + center = center, + ) + } + repeat(3) { + val center = Offset( + x = random.nextFloat() * size.width, + y = random.nextFloat() * size.height, + ) + val radius = size.minDimension * (0.03f + random.nextFloat() * 0.05f) + drawCircle( + color = Color.White.copy(alpha = 0.28f), + radius = radius, + center = center, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt index ec481a72..b2a34f72 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt @@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.crossfade import okio.Path import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.OutlineButton @@ -65,10 +61,6 @@ import spotube.composeapp.generated.resources.plugin_action_logout import spotube.composeapp.generated.resources.plugin_state_active import spotube.composeapp.generated.resources.plugin_state_builtin import spotube.composeapp.generated.resources.plugin_version_label -import spotube.composeapp.generated.resources.settings_plugins_ability_audio -import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics -import spotube.composeapp.generated.resources.settings_plugins_ability_metadata -import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble @Composable internal fun PluginCard( @@ -96,26 +88,22 @@ internal fun PluginCard( .clip(RoundedCornerShape(10.dp)), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) ) { - if (logoPath != null) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(logoPath.toString()) - .crossfade(true) - .build(), - contentDescription = plugin.name, - modifier = Modifier.fillMaxSize() - ) - } else { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - } - } + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Box(contentAlignment = Alignment.Center) { + Icon( + Iconsax.IconsaxBox, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + }, + ) } // Text content @@ -302,12 +290,3 @@ internal fun PluginCard( } } -@Composable -private fun PluginAbility.displayLabel(): String { - return when (this) { - PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) - PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) - PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) - PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt new file mode 100644 index 00000000..6db4d779 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt @@ -0,0 +1,76 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.plugin.components + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import dev.krtirtho.spotube.modules.plugin.PluginEntry +import dev.krtirtho.spotube.modules.plugin.builtInPluginLogo +import okio.Path +import org.jetbrains.compose.resources.painterResource + +/** + * Renders a plugin's logo, resolving the best available source: + * + * 1. the plugin's own `logo.png` ([logoPath]) when it was installed from disk, + * 2. the bundled placeholder logo for built-in plugins (see [builtInPluginLogo]), + * 3. [fallback] (for example the ability icon) when no logo is available. + */ +@Composable +fun PluginLogo( + plugin: PluginEntry?, + logoPath: Path?, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Crop, + fallback: @Composable () -> Unit, +) { + val builtInLogo = plugin?.let { builtInPluginLogo(it) } + + when { + logoPath != null -> { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(logoPath.toString()) + .crossfade(true) + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier, + ) + } + + builtInLogo != null -> { + Image( + painter = painterResource(builtInLogo), + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier, + ) + } + + else -> fallback() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt index b641fe89..e4f065d0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.crossfade import okio.Path import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.OutlineButton @@ -100,24 +96,20 @@ fun PluginPermissionDialog( .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { - if (logoPath != null) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(logoPath.toString()) - .crossfade(true) - .build(), - contentDescription = pluginInfo.name, - modifier = Modifier.fillMaxSize() - ) - } else { - Icon( - imageVector = Iconsax.IconsaxBoxAdd, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - } + PluginLogo( + plugin = pluginInfo, + logoPath = logoPath, + contentDescription = pluginInfo.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Icon( + imageVector = Iconsax.IconsaxBoxAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + }, + ) } Column(modifier = Modifier.weight(1f)) { Text( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt index 3264af61..6630b844 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt @@ -59,13 +59,6 @@ import spotube.composeapp.generated.resources.settings_section_jam fun JamSettingsScreen( viewModel: JamSettingsViewModel = koinViewModel(), ) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val broker = uiState.settings.jamBroker - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - Scaffold( topBar = { ApplicationMainBar( @@ -73,201 +66,222 @@ fun JamSettingsScreen( ) }, ) { innerPadding -> - Box( + JamSettingsContent( + viewModel = viewModel, + modifier = Modifier.padding(innerPadding), + ) + } +} + +/** + * The Group Jam settings UI without its own [Scaffold] / app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen]. + */ +@Composable +internal fun JamSettingsContent( + viewModel: JamSettingsViewModel = koinViewModel(), + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val broker = uiState.settings.jamBroker + val shellBottomInset = LocalAppShellBottomInset.current + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + Box( + modifier = modifier + .fillMaxSize(), + ) { + LazyColumn( modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding, ) { - LazyColumn( - modifier = Modifier - .widthIn(max = 1280.dp) - .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { - settingsSectionHeader(Res.string.settings_jam_section_connection) - settingsSectionCard( - items = listOf( - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_host), - value = broker.host, - placeholder = "broker.example.com", - icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") }, - onValueSaved = { host -> - viewModel.update { - copy( - jamBroker = jamBroker.copy( - host = host - ) + settingsSectionHeader(Res.string.settings_jam_section_connection) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_host), + value = broker.host, + placeholder = "broker.example.com", + icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") }, + onValueSaved = { host -> + viewModel.update { + copy( + jamBroker = jamBroker.copy( + host = host ) - } - }, - ) - }, - { - val errorWholeNumber = - stringResource(Res.string.settings_error_whole_number) - val errorPortRange = - stringResource(Res.string.settings_error_port_range) + ) + } + }, + ) + }, + { + val errorWholeNumber = + stringResource(Res.string.settings_error_whole_number) + val errorPortRange = + stringResource(Res.string.settings_error_port_range) - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_port), - value = broker.port.toString(), - placeholder = "1883", - normalize = { it.filter(Char::isDigit).take(5) }, - validate = { value -> - val port = value.toIntOrNull() - when { - port == null -> errorWholeNumber - port !in 1..65535 -> errorPortRange - else -> null - } - }, - onValueSaved = { port -> - viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) } - }, - icon = { - SettingsItemIcon( - Iconsax.CustomServer, - stringResource(Res.string.settings_jam_broker_port) - ) - }, - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_jam_broker_tls), - subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle), - checked = broker.useTls, - onCheckedChange = { tls -> - viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) } - }, - icon = { - SettingsItemIcon( - Iconsax.IconsaxWifiSquare, - stringResource(Res.string.settings_jam_broker_tls) - ) - }, - ) - }, - ), - ) - settingsSectionHeader(Res.string.settings_jam_section_credentials) - settingsSectionCard( - items = listOf( - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_username), - value = broker.username.orEmpty(), - placeholder = "anonymous", - onValueSaved = { username -> - viewModel.update { - copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) - } - }, - icon = { - SettingsItemIcon( - Iconsax.User, - stringResource(Res.string.settings_jam_broker_username) - ) - }, - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_password), - value = broker.password.orEmpty(), - placeholder = "••••••••", - onValueSaved = { password -> - viewModel.update { - copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) - } - }, - icon = { - SettingsItemIcon( - Iconsax.IconsaxEye, - stringResource(Res.string.settings_jam_broker_password) - ) - }, - ) - }, - ), - ) - settingsSectionHeader(Res.string.settings_jam_section_identity) - settingsSectionCard( - items = listOf( - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_client_id), - subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle), - value = broker.clientIdPrefix, - placeholder = "spotube", - onValueSaved = { prefix -> - viewModel.update { - copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) - } - }, - icon = { - SettingsItemIcon( - Iconsax.IconsaxTag, - stringResource(Res.string.settings_jam_broker_client_id) - ) - }, - ) - }, - ), - ) - settingsSectionHeader(Res.string.settings_jam_section_diagnostics) - settingsSectionCard( - items = listOf( - { - val canTest = broker.host.isNotBlank() && !uiState.isTesting - val resultMessage = uiState.testResult?.fold( - onSuccess = { it }, - onFailure = { "Failed: ${it.message ?: "Connection failed"}" }, - ) + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_port), + value = broker.port.toString(), + placeholder = "1883", + normalize = { it.filter(Char::isDigit).take(5) }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> errorWholeNumber + port !in 1..65535 -> errorPortRange + else -> null + } + }, + onValueSaved = { port -> + viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) } + }, + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_port) + ) + }, + ) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_jam_broker_tls), + subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle), + checked = broker.useTls, + onCheckedChange = { tls -> + viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxWifiSquare, + stringResource(Res.string.settings_jam_broker_tls) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_credentials) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_username), + value = broker.username.orEmpty(), + placeholder = "anonymous", + onValueSaved = { username -> + viewModel.update { + copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.User, + stringResource(Res.string.settings_jam_broker_username) + ) + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_password), + value = broker.password.orEmpty(), + placeholder = "••••••••", + onValueSaved = { password -> + viewModel.update { + copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxEye, + stringResource(Res.string.settings_jam_broker_password) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_identity) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_client_id), + subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle), + value = broker.clientIdPrefix, + placeholder = "spotube", + onValueSaved = { prefix -> + viewModel.update { + copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxTag, + stringResource(Res.string.settings_jam_broker_client_id) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_diagnostics) + settingsSectionCard( + items = listOf( + { + val canTest = broker.host.isNotBlank() && !uiState.isTesting + val resultMessage = uiState.testResult?.fold( + onSuccess = { it }, + onFailure = { "Failed: ${it.message ?: "Connection failed"}" }, + ) - SettingCardItem( - enabled = canTest, - title = stringResource(Res.string.settings_jam_broker_test), - subtitle = when { - uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing) - resultMessage != null -> resultMessage - else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle) - }, - icon = { - SettingsItemIcon( - Iconsax.IconsaxInformation, - stringResource(Res.string.settings_jam_broker_test), + SettingCardItem( + enabled = canTest, + title = stringResource(Res.string.settings_jam_broker_test), + subtitle = when { + uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing) + resultMessage != null -> resultMessage + else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle) + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxInformation, + stringResource(Res.string.settings_jam_broker_test), + ) + }, + trailingContent = { + PrimaryButton( + onClick = viewModel::testConnection, + enabled = canTest, + ) { + Text( + if (uiState.isTesting) { + stringResource(Res.string.settings_jam_broker_testing) + } else { + stringResource(Res.string.settings_jam_broker_test) + }, ) - }, - trailingContent = { - PrimaryButton( - onClick = viewModel::testConnection, - enabled = canTest, - ) { - Text( - if (uiState.isTesting) { - stringResource(Res.string.settings_jam_broker_testing) - } else { - stringResource(Res.string.settings_jam_broker_test) - }, - ) - } - }, - onClick = viewModel::testConnection, - ) - }, - ), + } + }, + onClick = viewModel::testConnection, + ) + }, + ), + ) + item { + Text( + text = stringResource(Res.string.settings_jam_broker_placeholder_note), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), ) - item { - Text( - text = stringResource(Res.string.settings_jam_broker_placeholder_note), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - ) - } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index f7119895..4430015c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube.modules.settings import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -37,6 +38,7 @@ import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* import dev.krtirtho.spotube.modules.settings.sections.appearanceSection @@ -52,17 +54,37 @@ import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +/** + * Minimum width of the settings content area required to show the settings as an + * overlay dialog instead of a full-screen destination. + */ +private val SettingsDialogMinWidth = 720.dp + +/** + * On large screens settings is presented as a modal overlay so it does not sit next to + * the main navigation sidebar. On smaller screens it stays a full-screen destination. + */ +@Composable +fun SettingsScreen(settingsViewModel: SettingsViewModel) { + val navigatorCommands: NavigationCommands = koinInject() + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + if (maxWidth >= SettingsDialogMinWidth) { + SettingsDialog( + settingsViewModel = settingsViewModel, + onDismiss = { navigatorCommands.pop(Routes.Settings) }, + ) + } else { + CompactSettingsScreen(settingsViewModel = settingsViewModel) + } + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(settingsViewModel: SettingsViewModel) { +private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { val navigatorCommands = koinInject() val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() - val platformType = remember { getPlatform().type } - val isDesktopPlatform = platformType == PlatformType.Windows || - platformType == PlatformType.Linux || - platformType == PlatformType.MacOS - val shellBottomInset = LocalAppShellBottomInset.current val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val contentPadding = remember(shellBottomInset) { @@ -79,66 +101,79 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { ) } ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - LazyColumn( - modifier = Modifier - .widthIn(max = 1280.dp) - .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { - pluginsSection( - navigatorCommands = navigatorCommands, - ) - if (settingsState != null) { - languageRegionSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - } - if (settingsState != null) - appearanceSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - if (settingsState != null) - playbackSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - navigatorCommands = navigatorCommands, - requestLocalNetworkPermission = requestLocalNetworkPermission, - ) - if (settingsState != null) - jamSection( - settings = settingsState!!, - navigationCommands = navigatorCommands, - ) - if (settingsState != null) - cacheSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - if (settingsState != null) - downloadsSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - if (isDesktopPlatform && settingsState != null) { - desktopSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - } - if (settingsState != null) - updatesSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - } + CompactSettingsContent( + settings = settingsState, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + contentPadding = contentPadding, + modifier = Modifier.padding(innerPadding), + ) + } +} +@Composable +private fun CompactSettingsContent( + settings: UserSettings?, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val platformType = remember { getPlatform().type } + val isDesktopPlatform = platformType == PlatformType.Windows || + platformType == PlatformType.Linux || + platformType == PlatformType.MacOS + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding, + ) { + pluginsSection( + navigatorCommands = navigatorCommands, + ) + if (settings != null) { + languageRegionSection( + settings = settings, + settingsViewModel = settingsViewModel, + ) + appearanceSection( + settings = settings, + settingsViewModel = settingsViewModel, + ) + playbackSection( + settings = settings, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + ) + jamSection( + settings = settings, + navigationCommands = navigatorCommands, + ) + cacheSection( + settings = settings, + settingsViewModel = settingsViewModel, + ) + downloadsSection( + settings = settings, + settingsViewModel = settingsViewModel, + ) + if (isDesktopPlatform) { + desktopSection( + settings = settings, + settingsViewModel = settingsViewModel, + ) + } + updatesSection( + settings = settings, + settingsViewModel = settingsViewModel, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt new file mode 100644 index 00000000..01e560cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt @@ -0,0 +1,356 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.PlatformType +import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.component.SidebarItem +import dev.krtirtho.spotube.modules.blacklist.BlacklistContent +import dev.krtirtho.spotube.modules.plugin.PluginScreenContent +import dev.krtirtho.spotube.modules.settings.sections.appearanceSection +import dev.krtirtho.spotube.modules.settings.sections.cacheSection +import dev.krtirtho.spotube.modules.settings.sections.desktopSection +import dev.krtirtho.spotube.modules.settings.sections.downloadsSection +import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection +import dev.krtirtho.spotube.modules.settings.sections.playbackSection +import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle +import dev.krtirtho.spotube.modules.settings.sections.updatesSection +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive +import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload +import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden +import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2 +import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation +import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_blacklist_title +import spotube.composeapp.generated.resources.settings_screen_title +import spotube.composeapp.generated.resources.settings_section_about +import spotube.composeapp.generated.resources.settings_section_appearance +import spotube.composeapp.generated.resources.settings_section_caching +import spotube.composeapp.generated.resources.settings_section_desktop +import spotube.composeapp.generated.resources.settings_section_downloads +import spotube.composeapp.generated.resources.settings_section_jam +import spotube.composeapp.generated.resources.settings_section_language_region +import spotube.composeapp.generated.resources.settings_section_playback +import spotube.composeapp.generated.resources.settings_section_plugins + +private val SettingsSidebarWidth = 236.dp + +/** + * The sections reachable from the large-screen settings sidebar, in display order. + * + * Each entry maps to either a list-based settings section or a standalone + * screen (Plugins, Blacklist, Group Jam) whose content is embedded in the tab. + */ +internal enum class SettingsTab( + val titleRes: StringResource, + val icon: ImageVector, +) { + Plugins(Res.string.settings_section_plugins, Iconsax.IconsaxBoxAdd), + LanguageRegion(Res.string.settings_section_language_region, Iconsax.IconsaxLanguageSquare), + Appearance(Res.string.settings_section_appearance, Iconsax.IconsaxColorSwatch), + Playback(Res.string.settings_section_playback, Iconsax.IconsaxMusicPlay), + Blacklist(Res.string.settings_blacklist_title, Iconsax.IconsaxForbidden), + GroupJam(Res.string.settings_section_jam, Iconsax.IconsaxSoundTwotone), + Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload), + Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive), + Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), + About(Res.string.settings_section_about, Iconsax.IconsaxInformation), +} + +/** + * Large-screen settings, presented as a modal overlay (dialog) over the whole app so it + * never sits next to the main navigation sidebar. + */ +@Composable +internal fun SettingsDialog( + settingsViewModel: SettingsViewModel, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + // The dialog is its own surface; the player/bottom bars behind it should not + // reserve any inset inside the dialog content. + CompositionLocalProvider(LocalAppShellBottomInset provides 0.dp) { + SettingsPane( + settingsViewModel = settingsViewModel, + onClose = onDismiss, + ) + } + } +} + +@Composable +private fun SettingsPane( + settingsViewModel: SettingsViewModel, + onClose: () -> Unit, +) { + val navigatorCommands: NavigationCommands = koinInject() + val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val contentPadding = remember { PaddingValues(top = 16.dp, bottom = 24.dp) } + val dialogTheme = LocalBaseUITheme.current.dialog + + val platformType = remember { getPlatform().type } + val isDesktopPlatform = platformType == PlatformType.Windows || + platformType == PlatformType.Linux || + platformType == PlatformType.MacOS + + val tabs = remember(isDesktopPlatform) { + SettingsTab.entries.filter { it != SettingsTab.Desktop || isDesktopPlatform } + } + var selectedIndex by rememberSaveable { mutableStateOf(0) } + val selectedTab = tabs.getOrElse(selectedIndex) { tabs.first() } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .widthIn(max = 1200.dp) + .heightIn(max = 860.dp) + .fillMaxWidth() + .fillMaxHeight() + .shadow( + elevation = dialogTheme.shadow.elevation, + shape = dialogTheme.shape, + ambientColor = dialogTheme.shadow.ambientColor, + spotColor = dialogTheme.shadow.spotColor, + ) + .clip(dialogTheme.shape) + .background(dialogTheme.background, dialogTheme.shape) + .border( + BorderStroke(dialogTheme.border.width, dialogTheme.border.color), + dialogTheme.shape, + ), + ) { + SettingsSidebar( + tabs = tabs, + selectedTab = selectedTab, + onSelect = { selectedIndex = tabs.indexOf(it) }, + ) + VerticalDivider( + modifier = Modifier.fillMaxHeight(), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + thickness = 1.dp, + ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + SettingsPageTitle(selectedTab.titleRes) + } + GhostIconButton( + onClick = onClose, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Close", + ) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + SettingsTabContent( + tab = selectedTab, + settings = settingsState, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + contentPadding = contentPadding, + ) + } + } + } + } +} + +@Composable +private fun SettingsSidebar( + tabs: List, + selectedTab: SettingsTab, + onSelect: (SettingsTab) -> Unit, +) { + LazyColumn( + modifier = Modifier + .width(SettingsSidebarWidth) + .fillMaxHeight() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item { + Text( + stringResource(Res.string.settings_screen_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 16.dp) + ) + } + items(tabs, key = { it.name }) { tab -> + SidebarItem( + label = stringResource(tab.titleRes), + icon = tab.icon, + selected = tab == selectedTab, + onClick = { onSelect(tab) }, + ) + } + } +} + +@Composable +private fun SettingsTabContent( + tab: SettingsTab, + settings: UserSettings?, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + contentPadding: PaddingValues, +) { + when (tab) { + SettingsTab.Plugins -> { + PluginScreenContent(viewModel = koinViewModel()) + } + + SettingsTab.Blacklist -> { + BlacklistContent(viewModel = koinViewModel()) + } + + SettingsTab.GroupJam -> { + JamSettingsContent(viewModel = koinViewModel()) + } + + else -> { + if (settings == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding, + ) { + when (tab) { + SettingsTab.LanguageRegion -> + languageRegionSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Appearance -> + appearanceSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Playback -> + playbackSection( + settings = settings, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + includeBlacklist = false, + includeHeader = false, + ) + + SettingsTab.Caching -> + cacheSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Downloads -> + downloadsSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Desktop -> + desktopSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.About -> + updatesSection(settings, settingsViewModel, includeHeader = false) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index 0838fc75..bc178108 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -67,8 +67,11 @@ import spotube.composeapp.generated.resources.* internal fun LazyListScope.appearanceSection( settings: UserSettings, settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true, ) { - settingsSectionHeader(Res.string.settings_section_appearance) + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_appearance) + } settingsSectionCard( items = listOf( { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt index 4ff417cc..c43fc60a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt @@ -42,8 +42,11 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.cacheSection( settings: UserSettings, settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true, ) { - settingsSectionHeader(Res.string.settings_section_caching) + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_caching) + } settingsSectionCard( items = listOf( { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt index 54de88c7..3095f488 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt @@ -30,8 +30,11 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.desktopSection( settings: UserSettings, settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true, ) { - settingsSectionHeader(Res.string.settings_section_desktop) + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_desktop) + } settingsSectionCard( items = listOf( { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index 5750a811..4acd6832 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -79,6 +79,7 @@ import spotube.composeapp.generated.resources.settings_subtitle_current internal fun LazyListScope.downloadsSection( settings: UserSettings, settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true, ) { val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) val downloadQualities = availableAudioQualities( @@ -86,7 +87,9 @@ internal fun LazyListScope.downloadsSection( current = settings.downloadMusicQuality, ) - settingsSectionHeader(Res.string.settings_section_downloads) + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_downloads) + } settingsSectionCard( items = listOf( { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt index e0e9545d..046a28df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -35,8 +35,11 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.languageRegionSection( settings: UserSettings, settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true, ) { - settingsSectionHeader(Res.string.settings_section_language_region) + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_language_region) + } settingsSectionCard( items = listOf( { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 52d0a190..0b4f515b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -20,6 +20,7 @@ package dev.krtirtho.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.Cast @@ -51,6 +52,8 @@ internal fun LazyListScope.playbackSection( settingsViewModel: SettingsViewModel, navigatorCommands: NavigationCommands, requestLocalNetworkPermission: () -> Unit, + includeBlacklist: Boolean = true, + includeHeader: Boolean = true, ) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( @@ -58,163 +61,162 @@ internal fun LazyListScope.playbackSection( current = settings.streamingMusicQuality, ) - settingsSectionHeader(Res.string.settings_section_playback) - settingsSectionCard( - items = listOf( - { - SelectionSettingCard( - title = stringResource(Res.string.settings_streaming_format_title), - subtitle = stringResource( - Res.string.settings_streaming_format_subtitle_current, - settings.streamingMusicFormat.displayLabel() - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title)) - }, - selectedOption = settings.streamingMusicFormat, - options = streamingFormats, - optionLabel = { it.displayLabel() }, - onOptionSelected = { format -> - settingsViewModel.updateSettings { - copy( - streamingMusicFormat = format, - streamingMusicQuality = format.resolveQuality(streamingMusicQuality), - ) - } - } - ) - }, - { - SelectionSettingCard( - title = stringResource(Res.string.settings_streaming_quality_title), - subtitle = stringResource( - Res.string.settings_subtitle_current, - settings.streamingMusicQuality.displayLabel() - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title)) - }, - selectedOption = settings.streamingMusicQuality, - options = streamingQualities, - optionLabel = { it.displayLabel() }, - onOptionSelected = { quality -> - settingsViewModel.updateSettings { - copy(streamingMusicQuality = quality) - } - } - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_endless_playback_title), - subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), - icon = { - SettingsItemIcon( - Iconsax.IconsaxRepeatArrow, - stringResource(Res.string.settings_enable_endless_playback_title) + val cards: List<@Composable () -> Unit> = buildList { + add { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_format_title), + subtitle = stringResource( + Res.string.settings_streaming_format_subtitle_current, + settings.streamingMusicFormat.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title)) + }, + selectedOption = settings.streamingMusicFormat, + options = streamingFormats, + optionLabel = { it.displayLabel() }, + onOptionSelected = { format -> + settingsViewModel.updateSettings { + copy( + streamingMusicFormat = format, + streamingMusicQuality = format.resolveQuality(streamingMusicQuality), ) - }, - checked = settings.enableEndlessPlayback, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableEndlessPlayback = enabled) - } } - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_connect_title), - subtitle = stringResource(Res.string.settings_enable_connect_subtitle), - icon = { - SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title)) - }, - checked = settings.enableConnect, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableConnect = enabled) - } + } + ) + } + add { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_quality_title), + subtitle = stringResource( + Res.string.settings_subtitle_current, + settings.streamingMusicQuality.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title)) + }, + selectedOption = settings.streamingMusicQuality, + options = streamingQualities, + optionLabel = { it.displayLabel() }, + onOptionSelected = { quality -> + settingsViewModel.updateSettings { + copy(streamingMusicQuality = quality) } - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_allow_remote_control_title), - subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), - icon = { - SettingsItemIcon( - Iconsax.IconsaxMirroringScreen, - stringResource(Res.string.settings_allow_remote_control_title) - ) - }, - checked = settings.allowRemoteControl, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(allowRemoteControl = enabled) - } - // Request the local network permission when enabling remote control - // so that DNS-SD registration can succeed on Android 16+ - if (enabled) { - requestLocalNetworkPermission() - } + } + ) + } + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_endless_playback_title), + subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxRepeatArrow, + stringResource(Res.string.settings_enable_endless_playback_title) + ) + }, + checked = settings.enableEndlessPlayback, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableEndlessPlayback = enabled) } - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_remote_device_name_title), - subtitle = stringResource( - Res.string.settings_remote_device_name_subtitle, - settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) - }, - value = settings.remoteControlDeviceName, - dialogDescription = stringResource(Res.string.settings_remote_device_name_description), - placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), - normalize = { it.trim() }, - validate = { _ -> null }, - onValueSaved = { value -> - settingsViewModel.updateSettings { - copy(remoteControlDeviceName = value) - } + } + ) + } + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_connect_title), + subtitle = stringResource(Res.string.settings_enable_connect_subtitle), + icon = { + SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title)) + }, + checked = settings.enableConnect, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableConnect = enabled) } - ) - }, - { - val error_whole_number = stringResource(Res.string.settings_error_whole_number) - val error_port_range = stringResource(Res.string.settings_error_port_range) + } + ) + } + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } + } + ) + } + add { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + } + add { + val error_whole_number = stringResource(Res.string.settings_error_whole_number) + val error_port_range = stringResource(Res.string.settings_error_port_range) - TextInputSettingCard( - title = stringResource(Res.string.settings_playback_port_title), - subtitle = stringResource( - Res.string.settings_playback_port_subtitle_current, - settings.playbackProxyServerPort - ), - icon = { - SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title)) - }, - value = settings.playbackProxyServerPort.toString(), - dialogDescription = stringResource(Res.string.settings_playback_port_description), - placeholder = stringResource(Res.string.settings_playback_port_placeholder), - normalize = { it.trim() }, - validate = { value -> - val port = value.toIntOrNull() - when { - port == null -> error_whole_number - port !in 1..65535 -> error_port_range - else -> null - } - }, - onValueSaved = { value -> - settingsViewModel.updateSettings { - copy(playbackProxyServerPort = value.toInt()) - } + TextInputSettingCard( + title = stringResource(Res.string.settings_playback_port_title), + subtitle = stringResource( + Res.string.settings_playback_port_subtitle_current, + settings.playbackProxyServerPort + ), + icon = { + SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title)) + }, + value = settings.playbackProxyServerPort.toString(), + dialogDescription = stringResource(Res.string.settings_playback_port_description), + placeholder = stringResource(Res.string.settings_playback_port_placeholder), + normalize = { it.trim() }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> error_whole_number + port !in 1..65535 -> error_port_range + else -> null } - ) - }, - { + }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(playbackProxyServerPort = value.toInt()) + } + } + ) + } + if (includeBlacklist) { + add { SettingCardItem( title = stringResource(Res.string.settings_blacklist_title), subtitle = stringResource(Res.string.settings_blacklist_subtitle), @@ -235,8 +237,12 @@ internal fun LazyListScope.playbackSection( navigatorCommands.navigateTo(Routes.Blacklist) }, ) - }, - ) - ) -} + } + } + } + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_playback) + } + settingsSectionCard(items = cards) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt index 0981d0e7..f81cf926 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt @@ -32,8 +32,11 @@ import spotube.composeapp.generated.resources.settings_updates_auto_check_title internal fun LazyListScope.updatesSection( settings: UserSettings, settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true, ) { - settingsSectionHeader(Res.string.settings_section_updates) + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_updates) + } settingsSectionCard( items = listOf { SwitchSettingCard( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt index 4a897cf8..b2e99aa7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality @@ -103,6 +104,23 @@ internal fun LazyListScope.settingsSectionHeader(title: StringResource) { } } +/** + * A page-level title for a settings destination (used by the large-screen sidebar + * layout, where each section is its own page rather than one entry in a long list). + */ +@Composable +internal fun SettingsPageTitle(title: StringResource) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) +} + @Composable internal fun SettingsItemIcon( imageVector: ImageVector, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 36ede564..ce632b19 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -24,23 +24,18 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -51,7 +46,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -64,11 +58,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.copyPadding -import dev.krtirtho.spotube.core.ui.base.copyShape +import dev.krtirtho.spotube.core.ui.component.SidebarItem +import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.library.LibraryState @@ -150,20 +141,12 @@ fun AppSidebar( items(sidebarTabs.size) { index -> when (val tab = sidebarTabs[index]) { is NavigationItem.Group<*> -> { - androidx.compose.animation.AnimatedVisibility(visible = expanded) { - Text( - text = tab.title.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp) - ) - } + SidebarSectionLabel(text = tab.title, expanded = expanded) tab.items.forEach { val selected = navigationState.topLevelRoute == it.route SidebarItem( label = it.title, - activeIcon = it.icon, + icon = it.icon, onClick = { if (it.route == Routes.Library) { libraryState.currentTab.value = @@ -173,8 +156,17 @@ fun AppSidebar( }, selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected, expanded = expanded, - showDownloadBadge = it.route == Routes.Library - && it.data == LibraryTab.Downloads + badge = if (it.route == Routes.Library + && it.data == LibraryTab.Downloads + ) { + { + DownloadBadgeIndicator( + modifier = Modifier.align(Alignment.TopEnd) + ) + } + } else { + null + }, ) } @@ -184,7 +176,7 @@ fun AppSidebar( val selected = navigationState.topLevelRoute == tab.route SidebarItem( label = tab.title, - activeIcon = tab.icon, + icon = tab.icon, onClick = { navigator.navigate(tab.route) }, @@ -206,7 +198,7 @@ fun AppSidebar( HorizontalDivider() SidebarItem( label = "Settings", - activeIcon = Iconsax.IconsaxSetting2, + icon = Iconsax.IconsaxSetting2, onClick = { navigator.navigate(Routes.Settings) }, @@ -216,75 +208,3 @@ fun AppSidebar( Spacer(modifier = Modifier.height(120.dp)) } } - -@Composable -fun SidebarItem( - label: String, - activeIcon: ImageVector, - selected: Boolean, - expanded: Boolean, - onClick: () -> Unit, - showDownloadBadge: Boolean = false, -) { - val itemContent: @Composable RowScope.() -> Unit = { - Box(modifier = Modifier.size(16.dp)) { - Icon( - imageVector = activeIcon, - contentDescription = label, - tint = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - if (showDownloadBadge) { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { - Text( - text = label, - maxLines = 1, - softWrap = false, - style = LocalTextStyle.current.copy( - fontSize = MaterialTheme.typography.labelMedium.fontSize, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - color = if (selected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - ) - } - Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) - } - - val buttonModifier = Modifier - .fillMaxWidth() - .padding(horizontal = 7.dp, vertical = 2.dp) - val contentPadding = PaddingValues(horizontal = 5.dp) - - if (selected) { - SecondaryButton( - onClick = onClick, - modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.secondary - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(5.dp)), - content = itemContent, - ) - } else { - OutlineButton( - onClick = onClick, - modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.outline - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(5.dp)), - hoverOnly = true, - content = itemContent, - ) - } -} \ No newline at end of file From 4da5b25a5ca3f10325071ffa51c4e6851f54e629 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 7 Oct 2026 15:29:25 +0600 Subject: [PATCH 034/132] feat(surface): implement Frutiger-Aero themed surfaces with glass and acrylic styles --- .../krtirtho/spotube/core/ui/base/Surface.kt | 465 ++++++++++++ .../krtirtho/spotube/core/ui/base/Theme.kt | 683 +++++++----------- .../krtirtho/spotube/core/ui/base/Utils.kt | 20 +- .../spotube/modules/shell/AppSidebar.kt | 15 +- 4 files changed, 743 insertions(+), 440 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt new file mode 100644 index 00000000..b3ad145f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt @@ -0,0 +1,465 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.ui.base + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.random.Random + +/** + * The Frutiger-Aero material families available to [BaseSurface]. + * + * - [Watery]: deep aqua water with caustic light and bubbles. + * - [Icy]: pale frosted ice with crystalline facets and frost. + * - [Chrome]: brushed metal with a hard specular reflection. + * - [Acrylic]: translucent tinted panel with fine grain. + * - [Glass]: clear, glossy glass with a strong sheen. + */ +enum class SurfaceStyle { + Watery, + Icy, + Chrome, + Acrylic, + Glass, +} + +/** + * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials. + * + * Distinct from [androidx.compose.material3.Surface] — this draws decorative material + * backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching + * [LocalContentColor] so text/content inside stays legible. + */ +@Composable +fun BaseSurface( + modifier: Modifier = Modifier, + style: SurfaceStyle = SurfaceStyle.Glass, + shape: Shape = RectangleShape, + elevation: Dp? = null, + contentColor: Color? = null, + contentPadding: PaddingValues = PaddingValues.Zero, + contentAlignment: Alignment = Alignment.TopStart, + content: @Composable BoxScope.() -> Unit, +) { + val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f + val onSurface = MaterialTheme.colorScheme.onSurface + + val resolvedElevation = elevation ?: when (style) { + SurfaceStyle.Watery -> 14.dp + SurfaceStyle.Icy -> 12.dp + SurfaceStyle.Chrome -> 10.dp + SurfaceStyle.Acrylic -> 16.dp + SurfaceStyle.Glass -> 12.dp + } + val resolvedContentColor = contentColor ?: when (style) { + SurfaceStyle.Watery -> Color.White + SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7) + SurfaceStyle.Chrome -> Color(0xFF1B2733) + SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface + } + val ambient = when (style) { + SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f) + SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f) + SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f) + SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f) + SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f) + } + val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f)) + + val borderBrush = when (style) { + SurfaceStyle.Watery -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), + ) + + SurfaceStyle.Icy -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)), + ) + + SurfaceStyle.Chrome -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)), + ) + + SurfaceStyle.Acrylic -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)), + ) + + SurfaceStyle.Glass -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)), + ) + } + + // Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn + // behind the node and, because the panel is see-through, shows up as a dark inset + // shape. Their depth comes from the edge, sheen and bevel instead. + val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic + + CompositionLocalProvider(LocalContentColor provides resolvedContentColor) { + Box( + modifier = modifier + .then( + if (castsShadow) { + Modifier.shadow( + elevation = resolvedElevation, + shape = shape, + ambientColor = ambient, + spotColor = spot, + ) + } else { + Modifier + } + ) + .clip(shape) + .drawWithCache { + onDrawBehind { drawSurfaceMaterial(style, isLight) } + } + .border(BorderStroke(1.dp, borderBrush), shape) + .padding(contentPadding), + contentAlignment = contentAlignment, + content = content, + ) + } +} + +private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) { + when (style) { + SurfaceStyle.Watery -> drawWatery(isLight) + SurfaceStyle.Icy -> drawIcy(isLight) + SurfaceStyle.Chrome -> drawChrome() + SurfaceStyle.Acrylic -> drawAcrylic(isLight) + SurfaceStyle.Glass -> drawGlass(isLight) + } +} + +private fun DrawScope.drawGlass(isLight: Boolean) { + // See-through glass: a faint blue-tinted fill so whatever is behind shows through, + // defined by its bright edge, sheen and shadow rather than an opaque body. + val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) + drawRect( + brush = Brush.verticalGradient( + listOf( + tint.copy(alpha = if (isLight) 0.4f else 0.2f), + tint.copy(alpha = if (isLight) 0.24f else 0.1f), + ), + ), + ) + drawGloss(if (isLight) 0.55f else 0.16f) + drawBottomReflection(if (isLight) 0.4f else 0.1f) + drawInnerBevel(isLight) + drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) +} + +private fun DrawScope.drawAcrylic(isLight: Boolean) { + // See-through acrylic: a translucent tinted sheet with a fine grain. + val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8) + drawRect( + brush = Brush.verticalGradient( + listOf( + tint.copy(alpha = if (isLight) 0.45f else 0.22f), + tint.copy(alpha = if (isLight) 0.28f else 0.12f), + ), + ), + ) + + // Fine grain so the panel reads as a translucent acrylic sheet. + val random = Random(23) + repeat(180) { + val light = random.nextBoolean() + drawCircle( + color = if (light) { + Color.White.copy(alpha = if (isLight) 0.16f else 0.06f) + } else { + Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f) + }, + radius = 0.7f, + center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), + ) + } + drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) + drawInnerBevel(isLight) +} + +private fun DrawScope.drawChrome() { + drawRect( + brush = Brush.verticalGradient( + 0f to Color(0xFFF8FBFF), + 0.16f to Color(0xFFD6DEE7), + 0.4f to Color(0xFF8E99A8), + 0.5f to Color(0xFFEDF2F7), + 0.62f to Color(0xFF9AA5B4), + 1f to Color(0xFF6C7787), + ), + ) + // Hard specular reflection near the top. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent), + startY = size.height * 0.05f, + endY = size.height * 0.22f, + ), + ) + // Darkened lower edge for the metal bevel. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)), + startY = size.height * 0.78f, + endY = size.height, + ), + ) +} + +private fun DrawScope.drawIcy(isLight: Boolean) { + val colors = if (isLight) { + listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0)) + } else { + // Snowy-night ice: dim slate-blue with faint frost. + listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26)) + } + drawRect(brush = Brush.verticalGradient(colors)) + + val frost = if (isLight) Color.White else Color(0xFFB8CCE0) + + // Crystalline facets (kept inset so they are never clipped by the shape). + val random = Random(37) + repeat(4) { + val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f) + val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f) + val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f) + val path = Path().apply { + moveTo(x, y) + lineTo(x + s, y + s * 0.4f) + lineTo(x + s * 0.4f, y + s) + close() + } + drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f)) + } + + // Frost speckles. + repeat(90) { + drawCircle( + color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), + radius = 0.8f, + center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), + ) + } + drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f) +} + +private fun DrawScope.drawWatery(isLight: Boolean) { + val colors = if (isLight) { + listOf(Color(0xFFA6E3FF), Color(0xFF3FB6EA), Color(0xFF0E6FB0)) + } else { + // Moonlit night water: deep blue with a pale silver sheen. + listOf(Color(0xFF1C3A5E), Color(0xFF102942), Color(0xFF071827)) + } + drawRect(brush = Brush.verticalGradient(colors)) + + val sheen = if (isLight) Color.White else Color(0xFFB9D2F0) + + // Caustic / moonlight pooling from the surface. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent), + startY = 0f, + endY = size.height * 0.5f, + ), + ) + + val random = Random(53) + repeat(5) { + val rx = size.width * (0.12f + random.nextFloat() * 0.2f) + val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) + val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f) + drawOval( + brush = Brush.radialGradient( + colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent), + center = Offset(cx, cy), + radius = rx, + ), + topLeft = Offset(cx - rx, cy - rx * 0.5f), + size = Size(rx * 2f, rx), + ) + } + + drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f) +} + +private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = alpha), Color.Transparent), + startY = 0f, + endY = size.height * heightFraction, + ), + ) +} + +private fun DrawScope.drawBottomReflection(alpha: Float) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.White.copy(alpha = alpha)), + startY = size.height * 0.7f, + endY = size.height, + ), + ) +} + +/** A bright inner top edge plus a shaded inner bottom edge, giving the pane a glass bevel. */ +private fun DrawScope.drawInnerBevel(isLight: Boolean) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), Color.Transparent), + startY = 0f, + endY = size.height * 0.1f, + ), + ) + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = if (isLight) 0.08f else 0.28f)), + startY = size.height * 0.9f, + endY = size.height, + ), + ) +} + +private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) { + val random = Random(seed) + repeat(count) { + val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f) + // Keep bubbles inset so they are never clipped into hard arcs at the edges. + val cx = radius + random.nextFloat() * (size.width - 2 * radius).coerceAtLeast(0f) + val cy = radius + random.nextFloat() * (size.height - 2 * radius).coerceAtLeast(0f) + val center = Offset(cx, cy) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color.White.copy(alpha = alpha), Color.White.copy(alpha = 0f)), + center = center, + radius = radius, + ), + radius = radius, + center = center, + ) + } +} + +@BaseUIPhonePreview +@BaseUIDesktopPreview +@Composable +private fun BaseSurfacePreview() { + BaseUIPreview { + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SurfaceStyle.entries.forEach { style -> + BaseSurface( + style = style, + modifier = Modifier + .fillMaxWidth() + .height(84.dp), + ) { + Text( + text = style.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } +} + +@BaseUIPhonePreview +@Composable +private fun BaseSurfaceGridPreview() { + BaseUIPreview { + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SurfaceStyle.entries.chunked(2).forEach { rowStyles -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + rowStyles.forEach { style -> + BaseSurface( + style = style, + modifier = Modifier + .weight(1f) + .height(140.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = style.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Frutiger Aero", + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + repeat(2 - rowStyles.size) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt index e195ffc2..9e27fc39 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -219,19 +220,51 @@ fun rememberBaseUITheme(): BaseUITheme { val scheme = MaterialTheme.colorScheme val isLight = scheme.surface.luminance() > 0.5f - val containerLighter = if (isLight) Color.White else scheme.surfaceContainerHigh - val containerDarker = if (isLight) Color(0xFFF2F2F4) else scheme.surfaceContainer - val containerPressed = if (isLight) Color(0xFFE0E0E3) else scheme.surfaceContainerHighest - val highlight = if (isLight) Color.White.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.06f) + // ------------------------------------------------------------------ + // Frutiger-Aero glass palette. + // + // Surfaces are glossy, top-lit gradients with a strong white specular + // highlight and translucent "glass" edges, lifted by soft, coloured + // shadows — the aqua/glass language of Frutiger-Aero. + // ------------------------------------------------------------------ + val glassTop = if (isLight) { + Color.White + } else { + lerp(scheme.surfaceContainerHigh, Color.White, 0.10f) + } + val glassBottom = if (isLight) { + lerp(scheme.surface, Color.White, 0.04f) + } else { + scheme.surfaceContainer + } + val glassPressed = if (isLight) { + lerp(scheme.surfaceContainerHighest, Color.White, 0.06f) + } else { + lerp(scheme.surfaceContainerHighest, Color.Black, 0.08f) + } + + val gloss = if (isLight) Color.White.copy(alpha = 0.75f) else Color.White.copy(alpha = 0.18f) + val glossSoft = if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.12f) + val glassEdge = if (isLight) Color.White.copy(alpha = 0.95f) else Color.White.copy(alpha = 0.22f) + val glassBorder = if (isLight) { + lerp(scheme.outlineVariant, Color.White, 0.55f) + } else { + scheme.outlineVariant.copy(alpha = 0.5f) + } val shadowColor = scheme.onSurface - val border = scheme.outlineVariant - val secondaryContainer = scheme.secondaryContainer - val secondaryContainerPressed = secondaryContainer.copy(alpha = if (isLight) 0.85f else 0.92f) - val secondaryHighlight = - if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.08f) + fun glassBrush(top: Color, bottom: Color): Brush = + Brush.verticalGradient(listOf(top, bottom)) - val defaultShape = RoundedCornerShape(14.dp) + fun aqua(accent: Color): Brush = Brush.verticalGradient( + listOf( + lerp(accent, Color.White, 0.45f), + accent, + lerp(accent, Color.Black, 0.14f), + ) + ) + + val defaultShape = RoundedCornerShape(16.dp) val defaultShapeState = BaseUITheme.InteractionState(defaultShape, defaultShape, defaultShape, defaultShape) val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp) @@ -239,231 +272,208 @@ fun rememberBaseUITheme(): BaseUITheme { val noShadow = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent) val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow, noShadow) val defaultButtonPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 20.dp, vertical = 10.dp), - PaddingValues(horizontal = 20.dp, vertical = 10.dp), - PaddingValues(horizontal = 20.dp, vertical = 10.dp), - PaddingValues(horizontal = 20.dp, vertical = 10.dp), + PaddingValues(horizontal = 22.dp, vertical = 10.dp), + PaddingValues(horizontal = 22.dp, vertical = 10.dp), + PaddingValues(horizontal = 22.dp, vertical = 10.dp), + PaddingValues(horizontal = 22.dp, vertical = 10.dp), ) val iconButtonPadding = BaseUITheme.InteractionState( - PaddingValues(8.dp), - PaddingValues(8.dp), - PaddingValues(8.dp), - PaddingValues(8.dp), + PaddingValues(9.dp), + PaddingValues(9.dp), + PaddingValues(9.dp), + PaddingValues(9.dp), ) + // --- Glass (outline) ------------------------------------------------- val outlineColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), + background = glassBrush(glassTop, glassBottom), foreground = scheme.onSurface, - highlight = highlight, + highlight = gloss, ), hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), + background = glassBrush(glassTop, glassBottom), foreground = scheme.onSurface, - highlight = highlight, + highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f), ), pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerPressed, containerPressed)), + background = glassBrush(glassPressed, glassPressed), foreground = scheme.onSurface, - highlight = highlight, + highlight = glossSoft, ), focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), + background = glassBrush(glassTop, glassBottom), foreground = scheme.onSurface, - highlight = highlight, + highlight = gloss, ), ) - val outlineShadow = BaseUITheme.InteractionState( + val glassShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( - 6.dp, + 8.dp, true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) + shadowColor.copy(alpha = 0.16f), + shadowColor.copy(alpha = 0.2f), ), hovered = BaseUITheme.Shadow( - 9.dp, + 14.dp, true, shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) + shadowColor.copy(alpha = 0.26f), ), pressed = BaseUITheme.Shadow( - 1.dp, + 2.dp, true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) + shadowColor.copy(alpha = 0.1f), + shadowColor.copy(alpha = 0.12f), ), focused = BaseUITheme.Shadow( - 6.dp, + 8.dp, true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) + shadowColor.copy(alpha = 0.16f), + shadowColor.copy(alpha = 0.2f), ), ) - val outlineBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(border, 0.5.dp), - hovered = BaseUITheme.Border(border, 0.5.dp), - pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp), - focused = BaseUITheme.Border(border, 0.5.dp), + val glassBorderState = BaseUITheme.InteractionState( + normal = BaseUITheme.Border(glassBorder, 1.dp), + hovered = BaseUITheme.Border(glassEdge, 1.dp), + pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp), + focused = BaseUITheme.Border(glassEdge, 1.dp), ) + // --- Aqua accent (primary) ------------------------------------------- + val primaryEdge = lerp(scheme.primary, Color.White, 0.5f) val primaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), + normal = BaseUITheme.ButtonColors(aqua(scheme.primary), scheme.onPrimary, gloss), hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), + aqua(scheme.primary), + scheme.onPrimary, + Color.White.copy(alpha = if (isLight) 0.9f else 0.3f), ), pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - scheme.primary.copy(alpha = 0.85f), - scheme.primary - ) + glassBrush( + lerp(scheme.primary, Color.Black, 0.16f), + lerp(scheme.primary, Color.Black, 0.24f), ), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), + scheme.onPrimary, + glossSoft, ), + focused = BaseUITheme.ButtonColors(aqua(scheme.primary), scheme.onPrimary, gloss), ) val primaryShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( - 6.dp, + 10.dp, true, scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) + scheme.primary.copy(alpha = 0.42f), ), hovered = BaseUITheme.Shadow( - 9.dp, + 16.dp, true, scheme.primary.copy(alpha = 0.45f), - scheme.primary.copy(alpha = 0.5f) + scheme.primary.copy(alpha = 0.5f), ), pressed = BaseUITheme.Shadow( - 1.dp, + 3.dp, true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) + scheme.primary.copy(alpha = 0.22f), + scheme.primary.copy(alpha = 0.28f), ), focused = BaseUITheme.Shadow( - 6.dp, + 10.dp, true, scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) + scheme.primary.copy(alpha = 0.42f), ), ) val primaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(scheme.primary, 0.5.dp), - hovered = BaseUITheme.Border(scheme.primary, 0.5.dp), - pressed = BaseUITheme.Border(scheme.primary, 0.5.dp), - focused = BaseUITheme.Border(scheme.primary, 0.5.dp), + normal = BaseUITheme.Border(primaryEdge, 1.dp), + hovered = BaseUITheme.Border(primaryEdge, 1.dp), + pressed = BaseUITheme.Border(primaryEdge.copy(alpha = 0.8f), 1.dp), + focused = BaseUITheme.Border(primaryEdge, 1.dp), ) + // --- Secondary (soft aqua glass) ------------------------------------- + val secondaryBase = scheme.secondaryContainer val secondaryColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainer, - secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f) - ) - ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, + glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), + scheme.onSecondaryContainer, + gloss, ), hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainer, - secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f) - ) - ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, + glassBrush(lerp(secondaryBase, Color.White, 0.55f), secondaryBase), + scheme.onSecondaryContainer, + Color.White.copy(alpha = if (isLight) 0.85f else 0.24f), ), pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainerPressed, - secondaryContainerPressed - ) + glassBrush( + lerp(secondaryBase, Color.Black, 0.08f), + lerp(secondaryBase, Color.Black, 0.14f), ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, + scheme.onSecondaryContainer, + glossSoft, ), focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainer, - secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f) - ) - ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, + glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), + scheme.onSecondaryContainer, + gloss, ), ) val secondaryShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( - 6.dp, + 8.dp, true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) + secondaryBase.copy(alpha = 0.4f), + secondaryBase.copy(alpha = 0.5f), ), hovered = BaseUITheme.Shadow( - 9.dp, + 14.dp, true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) + secondaryBase.copy(alpha = 0.5f), + secondaryBase.copy(alpha = 0.6f), ), pressed = BaseUITheme.Shadow( - 1.dp, + 2.dp, true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) + secondaryBase.copy(alpha = 0.3f), + secondaryBase.copy(alpha = 0.35f), ), focused = BaseUITheme.Shadow( - 6.dp, + 8.dp, true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) + secondaryBase.copy(alpha = 0.4f), + secondaryBase.copy(alpha = 0.5f), ), ) val secondaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), - hovered = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), - pressed = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), - focused = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), + normal = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp), + hovered = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.6f), 1.dp), + pressed = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.4f), 1.dp), + focused = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp), ) + // --- Ghost ----------------------------------------------------------- val ghostColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), + background = glassBrush(Color.Transparent, Color.Transparent), foreground = scheme.onSurface, highlight = Color.Transparent, ), hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - containerPressed.copy(alpha = 0.5f), - containerPressed.copy(alpha = 0.5f) - ) + background = glassBrush( + lerp(scheme.surfaceContainerHigh, Color.White, 0.1f), + scheme.surfaceContainer, ), foreground = scheme.onSurface, - highlight = Color.Transparent, + highlight = glossSoft, ), pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerPressed, containerPressed)), + background = glassBrush(glassPressed, glassPressed), foreground = scheme.onSurface, highlight = Color.Transparent, ), focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), + background = glassBrush(Color.Transparent, Color.Transparent), foreground = scheme.onSurface, highlight = Color.Transparent, ), @@ -472,8 +482,8 @@ fun rememberBaseUITheme(): BaseUITheme { val outlineStyle = BaseUITheme.ButtonStyle( outlineColors, defaultShapeState, - outlineShadow, - outlineBorder, + glassShadow, + glassBorderState, defaultButtonPadding ) val primaryStyle = BaseUITheme.ButtonStyle( @@ -501,8 +511,8 @@ fun rememberBaseUITheme(): BaseUITheme { val outlineIconStyle = BaseUITheme.ButtonStyle( outlineColors, defaultShapeState, - outlineShadow, - outlineBorder, + glassShadow, + glassBorderState, iconButtonPadding ) val primaryIconStyle = BaseUITheme.ButtonStyle( @@ -534,20 +544,20 @@ fun rememberBaseUITheme(): BaseUITheme { focused = scheme.onSurface, ) - val checkBoxShape = RoundedCornerShape(6.dp) + val checkBoxShape = RoundedCornerShape(7.dp) val checkBoxShapeState = BaseUITheme.InteractionState(checkBoxShape, checkBoxShape, checkBoxShape, checkBoxShape) val noPadding = BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp)) - val chipTabShape = RoundedCornerShape(10.dp) + val chipTabShape = RoundedCornerShape(999.dp) val chipTabShapeState = BaseUITheme.InteractionState(chipTabShape, chipTabShape, chipTabShape, chipTabShape) val chipTabPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 14.dp, vertical = 4.dp), - PaddingValues(horizontal = 14.dp, vertical = 4.dp), - PaddingValues(horizontal = 14.dp, vertical = 4.dp), - PaddingValues(horizontal = 14.dp, vertical = 4.dp), + PaddingValues(horizontal = 16.dp, vertical = 6.dp), + PaddingValues(horizontal = 16.dp, vertical = 6.dp), + PaddingValues(horizontal = 16.dp, vertical = 6.dp), + PaddingValues(horizontal = 16.dp, vertical = 6.dp), ) val toggleShape = RoundedCornerShape(50) @@ -575,16 +585,16 @@ fun rememberBaseUITheme(): BaseUITheme { ), textField = TextFieldTheme( background = BaseUITheme.InteractionState( - normal = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - pressed = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)), + normal = glassBrush(glassTop, glassBottom), + hovered = glassBrush(glassTop, glassBottom), + pressed = glassBrush(glassPressed, glassPressed), + focused = glassBrush(glassTop, glassBottom), ), highlight = BaseUITheme.InteractionState( - normal = highlight, - hovered = highlight, - pressed = highlight, - focused = highlight, + normal = gloss, + hovered = gloss, + pressed = glossSoft, + focused = Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), ), shape = BaseUITheme.InteractionState( normal = defaultShape, @@ -593,53 +603,48 @@ fun rememberBaseUITheme(): BaseUITheme { focused = defaultShape, ), border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp), - hovered = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp), - pressed = BaseUITheme.Border(border, 0.5.dp), - focused = BaseUITheme.Border(scheme.primary, 0.5.dp), + normal = BaseUITheme.Border(glassBorder, 1.dp), + hovered = BaseUITheme.Border(glassEdge, 1.dp), + pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp), + focused = BaseUITheme.Border(primaryEdge, 1.5.dp), ), shadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( - 9.dp, + 8.dp, true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - hovered = BaseUITheme.Shadow( - 9.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - pressed = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.15f), + shadowColor.copy(alpha = 0.14f), shadowColor.copy(alpha = 0.18f) ), - focused = BaseUITheme.Shadow( - 9.dp, + hovered = BaseUITheme.Shadow( + 10.dp, true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) + shadowColor.copy(alpha = 0.18f), + shadowColor.copy(alpha = 0.22f) + ), + pressed = BaseUITheme.Shadow( + 4.dp, + true, + shadowColor.copy(alpha = 0.1f), + shadowColor.copy(alpha = 0.12f) + ), + focused = BaseUITheme.Shadow( + 10.dp, + true, + scheme.primary.copy(alpha = 0.22f), + scheme.primary.copy(alpha = 0.28f) ), ), - padding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), + padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), textStyle = TextStyle.Default, cursor = SolidColor(scheme.primary), foreground = textFieldForeground, ), autoCompleteMenuTheme = AutoCompleteMenuTheme( - background = Brush.verticalGradient( - listOf( - scheme.surfaceContainerHigh, - scheme.surfaceContainer - ) - ), - shape = RoundedCornerShape(12.dp), - border = BaseUITheme.Border(border, 0.5.dp), + background = glassBrush(glassTop, glassBottom), + shape = RoundedCornerShape(16.dp), + border = BaseUITheme.Border(glassBorder, 1.dp), padding = PaddingValues(0.dp), - shadowElevation = 8.dp, + shadowElevation = 16.dp, ), slider = SliderTheme( trackActiveColor = BaseUITheme.InteractionState( @@ -649,10 +654,10 @@ fun rememberBaseUITheme(): BaseUITheme { focused = scheme.primary, ), trackInactiveColor = BaseUITheme.InteractionState( - normal = border.copy(alpha = 0.6f), - hovered = border.copy(alpha = 0.6f), - pressed = border.copy(alpha = 0.6f), - focused = border.copy(alpha = 0.6f), + normal = glassBorder, + hovered = glassBorder, + pressed = glassBorder, + focused = glassBorder, ), thumbColor = BaseUITheme.InteractionState( normal = scheme.primary, @@ -664,94 +669,44 @@ fun rememberBaseUITheme(): BaseUITheme { normal = BaseUITheme.Shadow( 5.dp, true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) + scheme.primary.copy(alpha = 0.4f), + scheme.primary.copy(alpha = 0.45f) ), hovered = BaseUITheme.Shadow( 8.dp, true, - scheme.primary.copy(alpha = 0.4f), + scheme.primary.copy(alpha = 0.45f), scheme.primary.copy(alpha = 0.5f) ), pressed = BaseUITheme.Shadow( 2.dp, true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) + scheme.primary.copy(alpha = 0.25f), + scheme.primary.copy(alpha = 0.3f) ), focused = BaseUITheme.Shadow( 5.dp, true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) + scheme.primary.copy(alpha = 0.4f), + scheme.primary.copy(alpha = 0.45f) ), ), - stepActiveColor = scheme.onPrimary.copy(alpha = 0.5f), + stepActiveColor = Color.White.copy(alpha = 0.7f), stepInactiveColor = scheme.onSurface.copy(alpha = 0.25f), ), checkBox = CheckBoxTheme( selected = BaseUITheme.ButtonStyle( colors = primaryColors, shape = checkBoxShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - hovered = BaseUITheme.Shadow( - 8.dp, - true, - scheme.primary.copy(alpha = 0.4f), - scheme.primary.copy(alpha = 0.45f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) - ), - focused = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - ), + shadow = primaryShadow, border = primaryBorder, padding = noPadding, ), unselected = BaseUITheme.ButtonStyle( colors = outlineColors, shape = checkBoxShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 5.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.24f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) - ), - focused = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - ), - border = outlineBorder, + shadow = glassShadow, + border = glassBorderState, padding = noPadding, ), checkmarkColor = scheme.onPrimary, @@ -767,131 +722,24 @@ fun rememberBaseUITheme(): BaseUITheme { unselected = BaseUITheme.ButtonStyle( colors = outlineColors, shape = chipTabShapeState, - shadow = outlineShadow, - border = outlineBorder, + shadow = glassShadow, + border = glassBorderState, padding = chipTabPadding, ), ), toggle = BaseUITheme.ToggleTheme( checked = BaseUITheme.ButtonStyle( - colors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - scheme.primary.copy(alpha = 0.85f), - scheme.primary - ) - ), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - ), + colors = primaryColors, shape = toggleShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 4.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - hovered = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.4f), - scheme.primary.copy(alpha = 0.45f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) - ), - focused = BaseUITheme.Shadow( - 4.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - ), - border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(scheme.primary, 0.5.dp), - hovered = BaseUITheme.Border(scheme.primary, 0.5.dp), - pressed = BaseUITheme.Border(scheme.primary, 0.5.dp), - focused = BaseUITheme.Border(scheme.primary, 0.5.dp), - ), + shadow = primaryShadow, + border = primaryBorder, padding = togglePadding, ), unchecked = BaseUITheme.ButtonStyle( - colors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerPressed, containerPressed)), - foreground = scheme.onSurface, - highlight = highlight, - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - ), + colors = outlineColors, shape = toggleShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) - ), - focused = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - ), - border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(border, 0.5.dp), - hovered = BaseUITheme.Border(border, 0.5.dp), - pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp), - focused = BaseUITheme.Border(border, 0.5.dp), - ), + shadow = glassShadow, + border = glassBorderState, padding = togglePadding, ), thumb = BaseUITheme.InteractionState( @@ -928,61 +776,62 @@ fun rememberBaseUITheme(): BaseUITheme { ), ), card = BaseUITheme.CardTheme( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(border, 0.5.dp), + background = glassBrush(glassTop, glassBottom), + shape = RoundedCornerShape(20.dp), + border = BaseUITheme.Border(glassBorder, 1.dp), padding = PaddingValues(0.dp), shadow = BaseUITheme.Shadow( - 6.dp, + 12.dp, true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) + shadowColor.copy(alpha = 0.16f), + shadowColor.copy(alpha = 0.2f) ), ), listRowTile = BaseUITheme.ListRowTheme( background = BaseUITheme.AdvancedInteractionState( - normal = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), - hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - pressed = Brush.verticalGradient(listOf(containerPressed, containerPressed)), - focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - selected = Brush.verticalGradient( - listOf( - scheme.primaryContainer.copy(alpha = 0.3f), - scheme.primaryContainer.copy(alpha = 0.3f) - ) + normal = glassBrush(Color.Transparent, Color.Transparent), + hovered = glassBrush(glassTop, glassBottom), + pressed = glassBrush(glassPressed, glassPressed), + focused = glassBrush(glassTop, glassBottom), + selected = glassBrush( + lerp(scheme.primaryContainer, Color.White, 0.4f), + scheme.primaryContainer, ), - disabled = Brush.verticalGradient( - listOf( - containerLighter.copy(alpha = 0.5f), - containerDarker.copy(alpha = 0.5f) - ) + disabled = glassBrush( + glassTop.copy(alpha = 0.5f), + glassBottom.copy(alpha = 0.5f), ), ), shape = BaseUITheme.AdvancedInteractionState( - normal = RoundedCornerShape(8.dp), - hovered = RoundedCornerShape(8.dp), - pressed = RoundedCornerShape(8.dp), - focused = RoundedCornerShape(8.dp), - selected = RoundedCornerShape(8.dp), - disabled = RoundedCornerShape(8.dp), + normal = RoundedCornerShape(10.dp), + hovered = RoundedCornerShape(10.dp), + pressed = RoundedCornerShape(10.dp), + focused = RoundedCornerShape(10.dp), + selected = RoundedCornerShape(10.dp), + disabled = RoundedCornerShape(10.dp), ), border = BaseUITheme.AdvancedInteractionState( normal = BaseUITheme.Border(Color.Transparent, 0.dp), - hovered = BaseUITheme.Border(Color.Transparent, 0.dp), + hovered = BaseUITheme.Border(glassBorder, 1.dp), pressed = BaseUITheme.Border(Color.Transparent, 0.dp), - focused = BaseUITheme.Border(Color.Transparent, 0.dp), + focused = BaseUITheme.Border(glassBorder, 1.dp), selected = BaseUITheme.Border(Color.Transparent, 0.dp), disabled = BaseUITheme.Border(Color.Transparent, 0.dp), ), shadow = BaseUITheme.AdvancedInteractionState( - normal = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - hovered = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - pressed = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - selected = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - disabled = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), + normal = noShadow, + hovered = BaseUITheme.Shadow( + 6.dp, + true, + shadowColor.copy(alpha = 0.12f), + shadowColor.copy(alpha = 0.16f) + ), + pressed = noShadow, + focused = noShadow, + selected = noShadow, + disabled = noShadow, ), - padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), + padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), textStyle = BaseUITheme.AdvancedInteractionState( normal = TextStyle.Default, hovered = TextStyle.Default, @@ -1001,17 +850,12 @@ fun rememberBaseUITheme(): BaseUITheme { ), ), dialog = BaseUITheme.DialogTheme( - background = Brush.verticalGradient( - listOf( - scheme.surface, - scheme.surfaceContainerHigh, - ) - ), + background = glassBrush(glassTop, glassBottom), scrim = Color.Black.copy(alpha = 0.42f), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(border, 0.5.dp), + shape = RoundedCornerShape(24.dp), + border = BaseUITheme.Border(glassEdge, 1.dp), shadow = BaseUITheme.Shadow( - 24.dp, + 28.dp, true, shadowColor.copy(alpha = 0.3f), shadowColor.copy(alpha = 0.4f) @@ -1019,27 +863,20 @@ fun rememberBaseUITheme(): BaseUITheme { padding = PaddingValues(24.dp), ), dropdownMenu = BaseUITheme.DropdownMenuTheme( - background = Brush.verticalGradient( - listOf( - scheme.surfaceContainerHigh, - scheme.surfaceContainer, - ) - ), - shape = RoundedCornerShape(12.dp), - border = BaseUITheme.Border(border, 0.5.dp), + background = glassBrush(glassTop, glassBottom), + shape = RoundedCornerShape(16.dp), + border = BaseUITheme.Border(glassBorder, 1.dp), shadow = BaseUITheme.Shadow( - 12.dp, + 16.dp, true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - itemHoverBackground = Brush.verticalGradient( - listOf(containerLighter, containerDarker) + shadowColor.copy(alpha = 0.22f), + shadowColor.copy(alpha = 0.28f) ), + itemHoverBackground = glassBrush(glassTop, glassBottom), itemForeground = scheme.onSurface, - itemHighlight = highlight, - padding = PaddingValues(4.dp), - itemPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), + itemHighlight = gloss, + padding = PaddingValues(6.dp), + itemPadding = PaddingValues(horizontal = 14.dp, vertical = 11.dp), ), ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt index 19e986eb..96d44fd5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt @@ -24,15 +24,27 @@ import androidx.compose.ui.unit.dp fun Modifier.highlight(color: Color): Modifier = drawWithCache { - val highlightBrush = Brush.verticalGradient( - colors = listOf(color, Color.Transparent), + // Frutiger-Aero glass: a bright specular sheen across the top half plus a + // faint reflection rising from the bottom edge. + val topGloss = Brush.verticalGradient( + colors = listOf(color, color.copy(alpha = color.alpha * 0.25f), Color.Transparent), startY = 0f, - endY = size.height * 0.5f, + endY = size.height * 0.55f, + ) + val bottomReflection = Brush.verticalGradient( + colors = listOf(Color.Transparent, color.copy(alpha = color.alpha * 0.3f)), + startY = size.height * 0.72f, + endY = size.height, ) onDrawWithContent { drawContent() drawRect( - brush = highlightBrush, + brush = topGloss, + topLeft = androidx.compose.ui.geometry.Offset.Zero, + size = size, + ) + drawRect( + brush = bottomReflection, topLeft = androidx.compose.ui.geometry.Offset.Zero, size = size, ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index ce632b19..dd61c618 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -50,9 +50,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeColorEffect -import dev.chrisbanes.haze.blur.blurEffect -import dev.chrisbanes.haze.hazeEffect import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator @@ -85,21 +82,13 @@ fun AppSidebar( var expanded by rememberSaveable { mutableStateOf(true) } val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) val currentLibraryTab by libraryState.currentTab.collectAsState() - val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) val listState = rememberLazyListState() + Column( modifier = modifier .fillMaxHeight() - .width(width) - .hazeEffect(hazeState) { - blurEffect { - blurRadius = 20.dp - colorEffects = listOf( - HazeColorEffect.tint(surfaceTint) - ) - } - }, + .width(width), horizontalAlignment = Alignment.CenterHorizontally ) { Column(modifier = Modifier.weight(1f)) { From 9f96b07ebd19881e8b3d3b9170c0f37721dd9b11 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 7 Oct 2026 18:45:22 +0600 Subject: [PATCH 035/132] feat(ui): replace Scaffold with BaseScaffold for consistent surface theming across screens --- .../composeResources/values/strings.xml | 8 + .../kotlin/dev/krtirtho/spotube/App.kt | 8 +- .../spotube/core/ui/base/BaseUIColorScheme.kt | 302 ++++++++++++++++++ .../spotube/core/ui/base/BaseUIPreviews.kt | 9 +- .../krtirtho/spotube/core/ui/base/Scaffold.kt | 128 ++++++++ .../krtirtho/spotube/core/ui/base/Surface.kt | 117 +++++-- .../krtirtho/spotube/core/ui/base/Theme.kt | 159 ++++----- .../component/AdaptiveDropdownBottomSheet.kt | 4 +- .../core/ui/component/CollectionView.kt | 4 +- .../spotube/core/ui/component/ErrorDisplay.kt | 4 +- .../spotube/core/ui/component/TrackList.kt | 4 +- .../core/ui/component/cards/PlayableCard.kt | 4 +- .../core/webview/PlatformWebviewScreen.kt | 4 +- .../spotube/modules/artist/ArtistScreen.kt | 4 +- .../modules/blacklist/BlacklistScreen.kt | 6 +- .../spotube/modules/devices/DevicesScreen.kt | 4 +- .../modules/devices/RemoteControlScreen.kt | 4 +- .../spotube/modules/home/HomeScreen.kt | 4 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 4 +- .../spotube/modules/library/LibraryScreen.kt | 4 +- .../spotube/modules/lyrics/LyricsScreen.kt | 4 +- .../spotube/modules/plugin/PluginScreen.kt | 7 +- .../spotube/modules/search/SearchScreen.kt | 4 +- .../modules/settings/JamSettingsScreen.kt | 6 +- .../modules/settings/SettingsModels.kt | 2 + .../modules/settings/SettingsScreen.kt | 4 +- .../settings/sections/AppearanceSection.kt | 36 +++ .../modules/shell/AppExpandedPlayer.kt | 4 +- .../spotube/modules/shell/AppShell.kt | 10 +- .../spotube/modules/welcome/WelcomeScreen.kt | 4 +- .../spotube/modules/welcome/WelcomeVisuals.kt | 7 +- .../ui/component/ApplicationMainBar.jvm.kt | 14 +- .../ui/component/VerticalScrollbar.jvm.kt | 31 +- 33 files changed, 761 insertions(+), 157 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 75571344..81f11912 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -44,6 +44,14 @@ Dark System + Surface + Background material of the app. Current: %1$s + Watery + Icy + Chrome + Acrylic + Glass + Accent color Current palette: %1$s Accent color diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 6eb9bb8c..297f3998 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -35,6 +35,7 @@ import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries +import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope @@ -156,8 +157,11 @@ fun App( } SpotubeTheme(settings = userSettings) { - val baseUITheme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { + val baseUITheme = rememberBaseUITheme(surfaceTheme = userSettings.surfaceTheme) + CompositionLocalProvider( + LocalBaseUITheme provides baseUITheme, + LocalBaseUIColors provides baseUITheme.colors, + ) { Box(modifier = Modifier.fillMaxSize()) { val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt new file mode 100644 index 00000000..412c97b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt @@ -0,0 +1,302 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.ui.base + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance + +/** + * The app's own colour system, independent of [MaterialTheme]. + * + * Each [SurfaceStyle] is a complete visual theme with its own palette (backgrounds, + * surfaces, text and outlines) rather than Material's role colours. The user's chosen + * accent is threaded through as `primary`, but everything else comes from the surface + * theme, so the app never has to rely on Material's colour scheme. + */ +@Immutable +data class BaseUIColorScheme( + val surfaceTheme: SurfaceStyle, + val isLight: Boolean, + /** Full-bleed page background for the current surface theme. */ + val background: Brush, + val surface: Color, + val surfaceVariant: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val onBackground: Color, + val onSurface: Color, + val onSurfaceVariant: Color, + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val outline: Color, + val outlineVariant: Color, + val error: Color, + val onError: Color, + val scrim: Color, +) + +val LocalBaseUIColors = staticCompositionLocalOf { + error("No BaseUIColorScheme provided. Wrap your UI in a BaseUITheme provider.") +} + +/** + * Builds the [BaseUIColorScheme] for the selected [surfaceTheme] and accent. + * + * Light/dark is read from the surrounding [MaterialTheme] (which the app already drives + * from the user's theme setting), and the accent comes from the user's chosen accent + * colour. Everything else is derived from the surface theme palette. + */ +@Composable +fun rememberBaseUIColorScheme( + surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, + accentColor: Color? = null, +): BaseUIColorScheme { + val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f + val accent = accentColor ?: MaterialTheme.colorScheme.primary + return remember(surfaceTheme, isLight, accent) { + baseUIColorScheme(surfaceTheme, isLight, accent) + } +} + +fun baseUIColorScheme( + surfaceTheme: SurfaceStyle, + isLight: Boolean, + accent: Color, +): BaseUIColorScheme { + val palette = surfacePalette(surfaceTheme, isLight) + val onPrimary = if (accent.luminance() > 0.5f) Color(0xFF10161C) else Color.White + val primaryContainer = lerp(palette.surface, accent, if (isLight) 0.16f else 0.32f) + val onPrimaryContainer = if (isLight) { + lerp(accent, Color.Black, 0.55f) + } else { + lerp(accent, Color.White, 0.7f) + } + val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f) + + return BaseUIColorScheme( + surfaceTheme = surfaceTheme, + isLight = isLight, + background = Brush.verticalGradient( + listOf(palette.backgroundTop, palette.backgroundBottom), + ), + surface = palette.surface, + surfaceVariant = palette.surfaceVariant, + surfaceContainer = palette.surfaceContainer, + surfaceContainerHigh = palette.surfaceContainerHigh, + surfaceContainerHighest = palette.surfaceContainerHighest, + onBackground = palette.onBackground, + onSurface = palette.onSurface, + onSurfaceVariant = palette.onSurfaceVariant, + primary = accent, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondaryContainer = secondaryContainer, + onSecondaryContainer = palette.onSurface, + outline = palette.outline, + outlineVariant = palette.outlineVariant, + error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373), + onError = Color.White, + scrim = Color.Black.copy(alpha = 0.42f), + ) +} + +private class SurfacePalette( + val backgroundTop: Color, + val backgroundBottom: Color, + val surface: Color, + val surfaceVariant: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val onBackground: Color, + val onSurface: Color, + val onSurfaceVariant: Color, + val outline: Color, + val outlineVariant: Color, +) + +private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { + return if (isLight) { + when (style) { + SurfaceStyle.Glass -> SurfacePalette( + backgroundTop = Color(0xFFEAF0F7), + backgroundBottom = Color(0xFFD8E3F1), + surface = Color(0xFFF8FBFF), + surfaceVariant = Color(0xFFE4ECF5), + surfaceContainer = Color(0xFFEEF3F9), + surfaceContainerHigh = Color(0xFFF3F7FC), + surfaceContainerHighest = Color(0xFFE1E9F3), + onBackground = Color(0xFF1B2733), + onSurface = Color(0xFF1B2733), + onSurfaceVariant = Color(0xFF56677A), + outline = Color(0xFFA9B8C8), + outlineVariant = Color(0xFFD3DDE8), + ) + + SurfaceStyle.Acrylic -> SurfacePalette( + backgroundTop = Color(0xFFE6EAF0), + backgroundBottom = Color(0xFFD6DCE5), + surface = Color(0xFFF4F7FA), + surfaceVariant = Color(0xFFE1E7EE), + surfaceContainer = Color(0xFFEAEEF4), + surfaceContainerHigh = Color(0xFFF1F4F8), + surfaceContainerHighest = Color(0xFFDCE2E9), + onBackground = Color(0xFF1C222B), + onSurface = Color(0xFF1C222B), + onSurfaceVariant = Color(0xFF545E6C), + outline = Color(0xFFA6AEBC), + outlineVariant = Color(0xFFD2D8E0), + ) + + SurfaceStyle.Watery -> SurfacePalette( + backgroundTop = Color(0xFFD6F0FF), + backgroundBottom = Color(0xFFB4DFFA), + surface = Color(0xFFF0FAFF), + surfaceVariant = Color(0xFFDCF0FC), + surfaceContainer = Color(0xFFE7F5FE), + surfaceContainerHigh = Color(0xFFEFF9FF), + surfaceContainerHighest = Color(0xFFCFE9F8), + onBackground = Color(0xFF08324A), + onSurface = Color(0xFF08324A), + onSurfaceVariant = Color(0xFF3A6884), + outline = Color(0xFF8FBBD4), + outlineVariant = Color(0xFFC6E2F2), + ) + + SurfaceStyle.Icy -> SurfacePalette( + backgroundTop = Color(0xFFE8F7FF), + backgroundBottom = Color(0xFFC9E8FB), + surface = Color(0xFFF6FCFF), + surfaceVariant = Color(0xFFE0F1FC), + surfaceContainer = Color(0xFFEBF7FE), + surfaceContainerHigh = Color(0xFFF3FBFF), + surfaceContainerHighest = Color(0xFFD4EBFA), + onBackground = Color(0xFF0B3A55), + onSurface = Color(0xFF0B3A55), + onSurfaceVariant = Color(0xFF3D6C88), + outline = Color(0xFF93C0D9), + outlineVariant = Color(0xFFC9E5F4), + ) + + SurfaceStyle.Chrome -> SurfacePalette( + backgroundTop = Color(0xFFE4E8ED), + backgroundBottom = Color(0xFFD0D6DE), + surface = Color(0xFFF4F7FA), + surfaceVariant = Color(0xFFE0E5EB), + surfaceContainer = Color(0xFFE9EDF2), + surfaceContainerHigh = Color(0xFFF1F4F7), + surfaceContainerHighest = Color(0xFFDBE0E7), + onBackground = Color(0xFF1B2733), + onSurface = Color(0xFF1B2733), + onSurfaceVariant = Color(0xFF525C6A), + outline = Color(0xFFA2AAB6), + outlineVariant = Color(0xFFD0D5DC), + ) + } + } else { + when (style) { + SurfaceStyle.Glass -> SurfacePalette( + backgroundTop = Color(0xFF0E1217), + backgroundBottom = Color(0xFF161C24), + surface = Color(0xFF1E242C), + surfaceVariant = Color(0xFF262E38), + surfaceContainer = Color(0xFF1A2027), + surfaceContainerHigh = Color(0xFF232A33), + surfaceContainerHighest = Color(0xFF2C343E), + onBackground = Color(0xFFE6EDF5), + onSurface = Color(0xFFE6EDF5), + onSurfaceVariant = Color(0xFF9AA7B6), + outline = Color(0xFF46525F), + outlineVariant = Color(0xFF2C343E), + ) + + SurfaceStyle.Acrylic -> SurfacePalette( + backgroundTop = Color(0xFF101317), + backgroundBottom = Color(0xFF181C22), + surface = Color(0xFF20262E), + surfaceVariant = Color(0xFF282F39), + surfaceContainer = Color(0xFF1C2229), + surfaceContainerHigh = Color(0xFF252C35), + surfaceContainerHighest = Color(0xFF2E3640), + onBackground = Color(0xFFE4EAF1), + onSurface = Color(0xFFE4EAF1), + onSurfaceVariant = Color(0xFF98A2B0), + outline = Color(0xFF48525E), + outlineVariant = Color(0xFF2E3640), + ) + + SurfaceStyle.Watery -> SurfacePalette( + backgroundTop = Color(0xFF06121E), + backgroundBottom = Color(0xFF0B1F31), + surface = Color(0xFF0E2233), + surfaceVariant = Color(0xFF153148), + surfaceContainer = Color(0xFF0B1D2C), + surfaceContainerHigh = Color(0xFF12293C), + surfaceContainerHighest = Color(0xFF183449), + onBackground = Color(0xFFD6EAF7), + onSurface = Color(0xFFD6EAF7), + onSurfaceVariant = Color(0xFF8FB0C6), + outline = Color(0xFF3C5A72), + outlineVariant = Color(0xFF183449), + ) + + SurfaceStyle.Icy -> SurfacePalette( + backgroundTop = Color(0xFF0C1620), + backgroundBottom = Color(0xFF132331), + surface = Color(0xFF16283A), + surfaceVariant = Color(0xFF1E3448), + surfaceContainer = Color(0xFF122130), + surfaceContainerHigh = Color(0xFF1A2E42), + surfaceContainerHighest = Color(0xFF213A50), + onBackground = Color(0xFFDCEBF7), + onSurface = Color(0xFFDCEBF7), + onSurfaceVariant = Color(0xFF92B3CA), + outline = Color(0xFF40607A), + outlineVariant = Color(0xFF213A50), + ) + + SurfaceStyle.Chrome -> SurfacePalette( + backgroundTop = Color(0xFF101317), + backgroundBottom = Color(0xFF181C22), + surface = Color(0xFF232830), + surfaceVariant = Color(0xFF2B313A), + surfaceContainer = Color(0xFF1E232A), + surfaceContainerHigh = Color(0xFF272D36), + surfaceContainerHighest = Color(0xFF303742), + onBackground = Color(0xFFE7ECF2), + onSurface = Color(0xFFE7ECF2), + onSurfaceVariant = Color(0xFF9BA5B2), + outline = Color(0xFF4B5561), + outlineVariant = Color(0xFF303742), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt index 1a5cc11c..7ce9a6f2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt @@ -48,15 +48,20 @@ import androidx.compose.ui.tooling.preview.Preview @Composable internal fun BaseUIPreview( modifier: Modifier = Modifier, + surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, content: @Composable BoxScope.() -> Unit, ) { MaterialTheme( colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(), ) { - CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) { + val theme = rememberBaseUITheme(surfaceTheme = surfaceTheme) + CompositionLocalProvider( + LocalBaseUITheme provides theme, + LocalBaseUIColors provides theme.colors, + ) { Surface( modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, + color = theme.colors.surface, ) { Box(modifier = Modifier.fillMaxSize(), content = content) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt new file mode 100644 index 00000000..59a6ea61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt @@ -0,0 +1,128 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.ui.base + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * A theme-aware replacement for [Scaffold]. + * + * Material's [Scaffold] paints Material's own surface colour, which hides the selected + * [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead: + * + * - is **transparent by default**, so the app-wide themed backdrop shows through, or + * - paints a chosen [surface] material locally, or + * - uses an explicit [containerColor] when one is given. + * + * It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host, + * floating action button) and hands the resolved [PaddingValues] to [content]. + */ +@Composable +fun BaseScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + surface: SurfaceStyle? = null, + containerColor: Color? = null, + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = topBar, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + containerColor = Color.Transparent, + contentWindowInsets = contentWindowInsets, + ) { innerPadding -> + val backgroundModifier = when { + containerColor != null -> Modifier.background(containerColor) + surface != null -> { + val isLight = LocalBaseUIColors.current.isLight + Modifier.baseSurfaceBackdrop(surface, isLight) + } + + else -> Modifier + } + Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) { + content(innerPadding) + } + } +} + +@BaseUIPhonePreview +@BaseUIDesktopPreview +@Composable +private fun BaseScaffoldPreview() { + BaseUIPreview { + val style = LocalBaseUITheme.current.surfaceTheme + BaseScaffold( + surface = style, + topBar = { + Text( + text = "Title", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + }, + bottomBar = { + Text( + text = "Bottom bar", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Content") + Text("Sits on the selected surface theme.", style = MaterialTheme.typography.bodySmall) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt index b3ad145f..f10ae714 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -52,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.random.Random +import kotlinx.serialization.Serializable /** * The Frutiger-Aero material families available to [BaseSurface]. @@ -61,7 +63,10 @@ import kotlin.random.Random * - [Chrome]: brushed metal with a hard specular reflection. * - [Acrylic]: translucent tinted panel with fine grain. * - [Glass]: clear, glossy glass with a strong sheen. + * + * Each style doubles as a full app theme (see [BaseUIColorScheme]). */ +@Serializable enum class SurfaceStyle { Watery, Icy, @@ -101,7 +106,7 @@ fun BaseSurface( val resolvedContentColor = contentColor ?: when (style) { SurfaceStyle.Watery -> Color.White SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7) - SurfaceStyle.Chrome -> Color(0xFF1B2733) + SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2) SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface } val ambient = when (style) { @@ -167,11 +172,25 @@ fun BaseSurface( } } +/** + * Paints the full-bleed material of [style] as a page background: the material body plus + * its own sheen and reflections, but no panel bevel (a backdrop has no edges). + */ +fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier = + drawWithCache { + onDrawBehind { drawSurfaceBase(style, isLight) } + } + private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) { + drawSurfaceBase(style, isLight) + drawInnerBevel(isLight) +} + +private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) { when (style) { SurfaceStyle.Watery -> drawWatery(isLight) SurfaceStyle.Icy -> drawIcy(isLight) - SurfaceStyle.Chrome -> drawChrome() + SurfaceStyle.Chrome -> drawChrome(isLight) SurfaceStyle.Acrylic -> drawAcrylic(isLight) SurfaceStyle.Glass -> drawGlass(isLight) } @@ -191,7 +210,6 @@ private fun DrawScope.drawGlass(isLight: Boolean) { ) drawGloss(if (isLight) 0.55f else 0.16f) drawBottomReflection(if (isLight) 0.4f else 0.1f) - drawInnerBevel(isLight) drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) } @@ -222,28 +240,48 @@ private fun DrawScope.drawAcrylic(isLight: Boolean) { ) } drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) - drawInnerBevel(isLight) } -private fun DrawScope.drawChrome() { - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFFF8FBFF), - 0.16f to Color(0xFFD6DEE7), - 0.4f to Color(0xFF8E99A8), - 0.5f to Color(0xFFEDF2F7), - 0.62f to Color(0xFF9AA5B4), - 1f to Color(0xFF6C7787), - ), - ) - // Hard specular reflection near the top. - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent), - startY = size.height * 0.05f, - endY = size.height * 0.22f, - ), - ) +private fun DrawScope.drawChrome(isLight: Boolean) { + if (isLight) { + drawRect( + brush = Brush.verticalGradient( + 0f to Color(0xFFF8FBFF), + 0.16f to Color(0xFFD6DEE7), + 0.4f to Color(0xFF8E99A8), + 0.5f to Color(0xFFEDF2F7), + 0.62f to Color(0xFF9AA5B4), + 1f to Color(0xFF6C7787), + ), + ) + // Hard specular reflection near the top. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent), + startY = size.height * 0.05f, + endY = size.height * 0.22f, + ), + ) + } else { + // Dark gunmetal for the night variant. + drawRect( + brush = Brush.verticalGradient( + 0f to Color(0xFF3B424C), + 0.16f to Color(0xFF2A303A), + 0.4f to Color(0xFF1B2028), + 0.5f to Color(0xFF343B45), + 0.62f to Color(0xFF232830), + 1f to Color(0xFF141820), + ), + ) + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.18f), Color.Transparent), + startY = size.height * 0.05f, + endY = size.height * 0.22f, + ), + ) + } // Darkened lower edge for the metal bevel. drawRect( brush = Brush.verticalGradient( @@ -463,3 +501,36 @@ private fun BaseSurfaceGridPreview() { } } } + +@BaseUIPhonePreview +@BaseUIDesktopPreview +@Composable +private fun SurfaceThemeBackdropPreview() { + val isLight = LocalBaseUIColors.current.isLight + val onSurface = LocalBaseUIColors.current.onSurface + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SurfaceStyle.entries.forEach { style -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(66.dp) + .clip(RoundedCornerShape(14.dp)) + .baseSurfaceBackdrop(style, isLight), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = style.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (style == SurfaceStyle.Watery) Color.White else onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt index 9e27fc39..826a3319 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt @@ -42,6 +42,8 @@ import dev.krtirtho.spotube.core.ui.base.BaseUITheme.TextFieldTheme @Stable data class BaseUITheme( + val colors: BaseUIColorScheme, + val surfaceTheme: SurfaceStyle, val buttons: ButtonVariants, val iconButtons: ButtonVariants, val textField: TextFieldTheme, @@ -216,9 +218,12 @@ val LocalBaseUITheme = staticCompositionLocalOf { } @Composable -fun rememberBaseUITheme(): BaseUITheme { - val scheme = MaterialTheme.colorScheme - val isLight = scheme.surface.luminance() > 0.5f +fun rememberBaseUITheme( + surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, + accentColor: Color? = null, +): BaseUITheme { + val colors = rememberBaseUIColorScheme(surfaceTheme, accentColor) + val isLight = colors.isLight // ------------------------------------------------------------------ // Frutiger-Aero glass palette. @@ -230,28 +235,28 @@ fun rememberBaseUITheme(): BaseUITheme { val glassTop = if (isLight) { Color.White } else { - lerp(scheme.surfaceContainerHigh, Color.White, 0.10f) + lerp(colors.surfaceContainerHigh, Color.White, 0.10f) } val glassBottom = if (isLight) { - lerp(scheme.surface, Color.White, 0.04f) + lerp(colors.surface, Color.White, 0.04f) } else { - scheme.surfaceContainer + colors.surfaceContainer } val glassPressed = if (isLight) { - lerp(scheme.surfaceContainerHighest, Color.White, 0.06f) + lerp(colors.surfaceContainerHighest, Color.White, 0.06f) } else { - lerp(scheme.surfaceContainerHighest, Color.Black, 0.08f) + lerp(colors.surfaceContainerHighest, Color.Black, 0.08f) } val gloss = if (isLight) Color.White.copy(alpha = 0.75f) else Color.White.copy(alpha = 0.18f) val glossSoft = if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.12f) val glassEdge = if (isLight) Color.White.copy(alpha = 0.95f) else Color.White.copy(alpha = 0.22f) val glassBorder = if (isLight) { - lerp(scheme.outlineVariant, Color.White, 0.55f) + lerp(colors.outlineVariant, Color.White, 0.55f) } else { - scheme.outlineVariant.copy(alpha = 0.5f) + colors.outlineVariant.copy(alpha = 0.5f) } - val shadowColor = scheme.onSurface + val shadowColor = colors.onSurface fun glassBrush(top: Color, bottom: Color): Brush = Brush.verticalGradient(listOf(top, bottom)) @@ -288,22 +293,22 @@ fun rememberBaseUITheme(): BaseUITheme { val outlineColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( background = glassBrush(glassTop, glassBottom), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = gloss, ), hovered = BaseUITheme.ButtonColors( background = glassBrush(glassTop, glassBottom), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f), ), pressed = BaseUITheme.ButtonColors( background = glassBrush(glassPressed, glassPressed), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = glossSoft, ), focused = BaseUITheme.ButtonColors( background = glassBrush(glassTop, glassBottom), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = gloss, ), ) @@ -341,48 +346,48 @@ fun rememberBaseUITheme(): BaseUITheme { ) // --- Aqua accent (primary) ------------------------------------------- - val primaryEdge = lerp(scheme.primary, Color.White, 0.5f) + val primaryEdge = lerp(colors.primary, Color.White, 0.5f) val primaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors(aqua(scheme.primary), scheme.onPrimary, gloss), + normal = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss), hovered = BaseUITheme.ButtonColors( - aqua(scheme.primary), - scheme.onPrimary, + aqua(colors.primary), + colors.onPrimary, Color.White.copy(alpha = if (isLight) 0.9f else 0.3f), ), pressed = BaseUITheme.ButtonColors( glassBrush( - lerp(scheme.primary, Color.Black, 0.16f), - lerp(scheme.primary, Color.Black, 0.24f), + lerp(colors.primary, Color.Black, 0.16f), + lerp(colors.primary, Color.Black, 0.24f), ), - scheme.onPrimary, + colors.onPrimary, glossSoft, ), - focused = BaseUITheme.ButtonColors(aqua(scheme.primary), scheme.onPrimary, gloss), + focused = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss), ) val primaryShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 10.dp, true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.42f), + colors.primary.copy(alpha = 0.35f), + colors.primary.copy(alpha = 0.42f), ), hovered = BaseUITheme.Shadow( 16.dp, true, - scheme.primary.copy(alpha = 0.45f), - scheme.primary.copy(alpha = 0.5f), + colors.primary.copy(alpha = 0.45f), + colors.primary.copy(alpha = 0.5f), ), pressed = BaseUITheme.Shadow( 3.dp, true, - scheme.primary.copy(alpha = 0.22f), - scheme.primary.copy(alpha = 0.28f), + colors.primary.copy(alpha = 0.22f), + colors.primary.copy(alpha = 0.28f), ), focused = BaseUITheme.Shadow( 10.dp, true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.42f), + colors.primary.copy(alpha = 0.35f), + colors.primary.copy(alpha = 0.42f), ), ) val primaryBorder = BaseUITheme.InteractionState( @@ -393,16 +398,16 @@ fun rememberBaseUITheme(): BaseUITheme { ) // --- Secondary (soft aqua glass) ------------------------------------- - val secondaryBase = scheme.secondaryContainer + val secondaryBase = colors.secondaryContainer val secondaryColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), - scheme.onSecondaryContainer, + colors.onSecondaryContainer, gloss, ), hovered = BaseUITheme.ButtonColors( glassBrush(lerp(secondaryBase, Color.White, 0.55f), secondaryBase), - scheme.onSecondaryContainer, + colors.onSecondaryContainer, Color.White.copy(alpha = if (isLight) 0.85f else 0.24f), ), pressed = BaseUITheme.ButtonColors( @@ -410,12 +415,12 @@ fun rememberBaseUITheme(): BaseUITheme { lerp(secondaryBase, Color.Black, 0.08f), lerp(secondaryBase, Color.Black, 0.14f), ), - scheme.onSecondaryContainer, + colors.onSecondaryContainer, glossSoft, ), focused = BaseUITheme.ButtonColors( glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), - scheme.onSecondaryContainer, + colors.onSecondaryContainer, gloss, ), ) @@ -456,25 +461,25 @@ fun rememberBaseUITheme(): BaseUITheme { val ghostColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( background = glassBrush(Color.Transparent, Color.Transparent), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = Color.Transparent, ), hovered = BaseUITheme.ButtonColors( background = glassBrush( - lerp(scheme.surfaceContainerHigh, Color.White, 0.1f), - scheme.surfaceContainer, + lerp(colors.surfaceContainerHigh, Color.White, 0.1f), + colors.surfaceContainer, ), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = glossSoft, ), pressed = BaseUITheme.ButtonColors( background = glassBrush(glassPressed, glassPressed), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = Color.Transparent, ), focused = BaseUITheme.ButtonColors( background = glassBrush(Color.Transparent, Color.Transparent), - foreground = scheme.onSurface, + foreground = colors.onSurface, highlight = Color.Transparent, ), ) @@ -538,10 +543,10 @@ fun rememberBaseUITheme(): BaseUITheme { ) val textFieldForeground = BaseUITheme.InteractionState( - normal = scheme.onSurface, - hovered = scheme.onSurface, - pressed = scheme.onSurface, - focused = scheme.onSurface, + normal = colors.onSurface, + hovered = colors.onSurface, + pressed = colors.onSurface, + focused = colors.onSurface, ) val checkBoxShape = RoundedCornerShape(7.dp) @@ -571,6 +576,8 @@ fun rememberBaseUITheme(): BaseUITheme { ) return BaseUITheme( + colors = colors, + surfaceTheme = surfaceTheme, buttons = BaseUITheme.ButtonVariants( primary = primaryStyle, secondary = secondaryStyle, @@ -630,13 +637,13 @@ fun rememberBaseUITheme(): BaseUITheme { focused = BaseUITheme.Shadow( 10.dp, true, - scheme.primary.copy(alpha = 0.22f), - scheme.primary.copy(alpha = 0.28f) + colors.primary.copy(alpha = 0.22f), + colors.primary.copy(alpha = 0.28f) ), ), padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), textStyle = TextStyle.Default, - cursor = SolidColor(scheme.primary), + cursor = SolidColor(colors.primary), foreground = textFieldForeground, ), autoCompleteMenuTheme = AutoCompleteMenuTheme( @@ -648,10 +655,10 @@ fun rememberBaseUITheme(): BaseUITheme { ), slider = SliderTheme( trackActiveColor = BaseUITheme.InteractionState( - normal = scheme.primary, - hovered = scheme.primary, - pressed = scheme.primary, - focused = scheme.primary, + normal = colors.primary, + hovered = colors.primary, + pressed = colors.primary, + focused = colors.primary, ), trackInactiveColor = BaseUITheme.InteractionState( normal = glassBorder, @@ -660,39 +667,39 @@ fun rememberBaseUITheme(): BaseUITheme { focused = glassBorder, ), thumbColor = BaseUITheme.InteractionState( - normal = scheme.primary, - hovered = scheme.primary, - pressed = scheme.primary, - focused = scheme.primary, + normal = colors.primary, + hovered = colors.primary, + pressed = colors.primary, + focused = colors.primary, ), thumbShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 5.dp, true, - scheme.primary.copy(alpha = 0.4f), - scheme.primary.copy(alpha = 0.45f) + colors.primary.copy(alpha = 0.4f), + colors.primary.copy(alpha = 0.45f) ), hovered = BaseUITheme.Shadow( 8.dp, true, - scheme.primary.copy(alpha = 0.45f), - scheme.primary.copy(alpha = 0.5f) + colors.primary.copy(alpha = 0.45f), + colors.primary.copy(alpha = 0.5f) ), pressed = BaseUITheme.Shadow( 2.dp, true, - scheme.primary.copy(alpha = 0.25f), - scheme.primary.copy(alpha = 0.3f) + colors.primary.copy(alpha = 0.25f), + colors.primary.copy(alpha = 0.3f) ), focused = BaseUITheme.Shadow( 5.dp, true, - scheme.primary.copy(alpha = 0.4f), - scheme.primary.copy(alpha = 0.45f) + colors.primary.copy(alpha = 0.4f), + colors.primary.copy(alpha = 0.45f) ), ), stepActiveColor = Color.White.copy(alpha = 0.7f), - stepInactiveColor = scheme.onSurface.copy(alpha = 0.25f), + stepInactiveColor = colors.onSurface.copy(alpha = 0.25f), ), checkBox = CheckBoxTheme( selected = BaseUITheme.ButtonStyle( @@ -709,7 +716,7 @@ fun rememberBaseUITheme(): BaseUITheme { border = glassBorderState, padding = noPadding, ), - checkmarkColor = scheme.onPrimary, + checkmarkColor = colors.onPrimary, ), chipTab = ChipTabTheme( selected = BaseUITheme.ButtonStyle( @@ -794,8 +801,8 @@ fun rememberBaseUITheme(): BaseUITheme { pressed = glassBrush(glassPressed, glassPressed), focused = glassBrush(glassTop, glassBottom), selected = glassBrush( - lerp(scheme.primaryContainer, Color.White, 0.4f), - scheme.primaryContainer, + lerp(colors.primaryContainer, Color.White, 0.4f), + colors.primaryContainer, ), disabled = glassBrush( glassTop.copy(alpha = 0.5f), @@ -841,12 +848,12 @@ fun rememberBaseUITheme(): BaseUITheme { disabled = TextStyle.Default, ), foreground = BaseUITheme.AdvancedInteractionState( - normal = scheme.onSurface, - hovered = scheme.onSurface, - pressed = scheme.onSurface, - focused = scheme.onSurface, - selected = scheme.onSurface, - disabled = scheme.onSurface.copy(alpha = 0.38f), + normal = colors.onSurface, + hovered = colors.onSurface, + pressed = colors.onSurface, + focused = colors.onSurface, + selected = colors.onSurface, + disabled = colors.onSurface.copy(alpha = 0.38f), ), ), dialog = BaseUITheme.DialogTheme( @@ -873,7 +880,7 @@ fun rememberBaseUITheme(): BaseUITheme { shadowColor.copy(alpha = 0.28f) ), itemHoverBackground = glassBrush(glassTop, glassBottom), - itemForeground = scheme.onSurface, + itemForeground = colors.onSurface, itemHighlight = gloss, padding = PaddingValues(6.dp), itemPadding = PaddingValues(horizontal = 14.dp, vertical = 11.dp), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index cc1068aa..352f0282 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -44,7 +44,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable @@ -327,7 +327,7 @@ private fun AdaptiveBottomSheetContent( @Preview @Composable private fun AdaptiveDropdownBottomSheetPreview() { - Scaffold { + BaseScaffold { AdaptiveDropdownBottomSheet( items = listOf( AdaptiveMenuItem( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index e4c9dd31..614999e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -103,7 +103,7 @@ fun CollectionView( } } - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( backButton = true, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt index 585ba1d5..7038d5b4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt @@ -37,7 +37,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -278,7 +278,7 @@ private fun ErrorDisplayPreview() { @Preview @Composable private fun ErrorLogDialogPreview() { - Scaffold { + BaseScaffold { ErrorLogDialog( errorMessage = "This is an error message", onDismiss = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index a985983a..c092a364 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -48,7 +48,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -1035,7 +1035,7 @@ fun previewTracks(): List { @Composable @Preview private fun TrackListPreview() { - Scaffold { + BaseScaffold { TrackList( tracks = previewTracks(), hasMore = true, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt index 14aee52b..89b9458c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt @@ -38,7 +38,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -189,7 +189,7 @@ fun PlayableCard( @PreviewLightDark @Composable private fun PlayableCardPreview() { - Scaffold { + BaseScaffold { PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index 8751e1ec..7d3373cd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -34,7 +34,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -148,7 +148,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) { } } - Scaffold( + BaseScaffold( contentWindowInsets = WindowInsets.statusBars, topBar = { ApplicationMainBar( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index ca2d0458..5d5afc72 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox @@ -118,7 +118,7 @@ fun ArtistScreen( val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar() } ) { innerPadding -> when (val currentState = state) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt index 8aad2670..9d2d6261 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -51,6 +50,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar @@ -67,7 +67,7 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun BlacklistScreen() { val viewModel = koinViewModel() - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( backButton = true, @@ -83,7 +83,7 @@ fun BlacklistScreen() { } /** - * The blacklist UI without its own [Scaffold] / app bar, so it can be embedded + * The blacklist UI without its own [BaseScaffold] / app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen]. */ @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index 95366a4a..a6c06f0d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -77,7 +77,7 @@ fun DevicesScreen( } } - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( backButton = true, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index 31949017..b0b42d5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -45,7 +45,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -107,7 +107,7 @@ fun RemoteControlScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter, ) { - Scaffold( + BaseScaffold( modifier = Modifier .fillMaxHeight(), topBar = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index 67924aaf..4b6782fe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -120,7 +120,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { } } - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( backButton = false, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index b7f76b00..f05cbc50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -32,7 +32,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -71,7 +71,7 @@ fun JamScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( backButton = true, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index c5d931ed..fdc33bfa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -57,7 +57,7 @@ fun LibraryScreen( val searchMode by libraryState.searchMode.collectAsState() val isLargeScreen = appShellViewModel.useSidebar() - Scaffold( + BaseScaffold( topBar = { Column { ApplicationMainBar( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index b1a0cbd4..6797df65 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -71,7 +71,7 @@ fun LyricsScreen( val positionMillis by viewModel.positionMillisFlow.collectAsState() val scope = rememberCoroutineScope() - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( backButton = onClose == null, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index 8fcd5baf..d8cc2ca5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -62,6 +61,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.core.extras.kebabToTitleCase +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton @@ -145,13 +145,12 @@ fun PluginScreen( viewModel: PluginViewModel = koinViewModel(), onboarding: Boolean = false, ) { - Scaffold( + BaseScaffold( topBar = { if (!onboarding) { ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) } }, - containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface, ) { innerPadding -> PluginScreenContent( viewModel = viewModel, @@ -162,7 +161,7 @@ fun PluginScreen( } /** - * The plugin management UI without its own [Scaffold] / app bar, so it can be embedded + * The plugin management UI without its own [BaseScaffold] / app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [PluginScreen]. */ @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index bb7fd14b..ba3a837e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -53,7 +53,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -268,7 +268,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar(backButton = false) } ) { innerPadding -> Column( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt index 6630b844..da022080 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable @@ -16,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard @@ -59,7 +59,7 @@ import spotube.composeapp.generated.resources.settings_section_jam fun JamSettingsScreen( viewModel: JamSettingsViewModel = koinViewModel(), ) { - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( title = { Text(stringResource(Res.string.settings_section_jam)) }, @@ -74,7 +74,7 @@ fun JamSettingsScreen( } /** - * The Group Jam settings UI without its own [Scaffold] / app bar, so it can be embedded + * The Group Jam settings UI without its own [BaseScaffold] / app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen]. */ @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index 55173afc..d51fb20e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.settings import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import dev.krtirtho.spotube.core.ui.base.SurfaceStyle import kotlinx.serialization.Serializable @Serializable @@ -35,6 +36,7 @@ data class UserSettings( // Appearance val theme: Theme = Theme.SYSTEM, val accentColor: AccentColors = AccentColors.GREEN_GOBLIN, + val surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, // Playback val streamingMusicFormat: AudioFormat = AudioFormat( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 4430015c..694c9d18 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -39,6 +38,7 @@ import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* import dev.krtirtho.spotube.modules.settings.sections.appearanceSection @@ -91,7 +91,7 @@ private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } - Scaffold( + BaseScaffold( topBar = { ApplicationMainBar( title = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index bc178108..54331e6f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -52,6 +52,7 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.Radio +import dev.krtirtho.spotube.core.ui.base.SurfaceStyle import dev.krtirtho.spotube.modules.settings.AccentColors import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.Theme @@ -61,6 +62,7 @@ import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* @@ -94,6 +96,29 @@ internal fun LazyListScope.appearanceSection( } ) }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_surface_theme_title), + subtitle = stringResource( + Res.string.settings_surface_theme_subtitle_current, + settings.surfaceTheme.displayLabel() + ), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMagic, + stringResource(Res.string.settings_surface_theme_title) + ) + }, + selectedOption = settings.surfaceTheme, + options = SurfaceStyle.entries, + optionLabel = { it.displayLabel() }, + onOptionSelected = { surfaceTheme -> + settingsViewModel.updateSettings { + copy(surfaceTheme = surfaceTheme) + } + } + ) + }, { AccentColorSettingCard( selectedAccent = settings.accentColor, @@ -120,6 +145,17 @@ private fun Theme.displayLabel(): String { } } +@Composable +private fun SurfaceStyle.displayLabel(): String { + return when (this) { + SurfaceStyle.Watery -> stringResource(Res.string.settings_surface_watery) + SurfaceStyle.Icy -> stringResource(Res.string.settings_surface_icy) + SurfaceStyle.Chrome -> stringResource(Res.string.settings_surface_chrome) + SurfaceStyle.Acrylic -> stringResource(Res.string.settings_surface_acrylic) + SurfaceStyle.Glass -> stringResource(Res.string.settings_surface_glass) + } +} + @Composable private fun AccentColors.displayLabel(): String { return when (this) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 0016bc80..c43837fd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -47,7 +47,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -235,7 +235,7 @@ fun AppExpandedPlayer( } } - Scaffold( + BaseScaffold( modifier = modifier.fillMaxSize(), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index 4d64ed63..6e79cfa3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -74,6 +74,8 @@ import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors +import dev.krtirtho.spotube.core.ui.base.baseSurfaceBackdrop import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent @@ -100,6 +102,7 @@ fun AppShell( ) { val navigatorCommands: NavigationCommands = koinInject() val remotePlaybackController: RemotePlaybackController = koinInject() + val baseUIColors = LocalBaseUIColors.current val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(remotePlaybackController) { remotePlaybackController.events.collect { message -> @@ -156,7 +159,7 @@ fun AppShell( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) + .baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight) .hazeSource(state = bgHazeState) // <-- Put it here! ) Row(modifier = Modifier.fillMaxSize()) { @@ -229,6 +232,11 @@ fun AppShell( } } else { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight), + ) CompactPlayerOverlay( navigator = navigator, navigationState = navigationState, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt index 94c2055e..61311d76 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -79,7 +79,7 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { WelcomeTheme { Box(Modifier.fillMaxSize()) { WelcomeBackground() - Scaffold( + BaseScaffold( containerColor = Color.Transparent, topBar = { ApplicationMainBar( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt index d5e50838..c06a5b98 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.TextUnit +import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import org.jetbrains.compose.resources.Font @@ -60,7 +61,11 @@ fun WelcomeTheme(content: @Composable () -> Unit) { outlineVariant = Color(0xFFD2D0CC), ) MaterialTheme(colorScheme = scheme) { - CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) { + val baseTheme = rememberBaseUITheme() + CompositionLocalProvider( + LocalBaseUITheme provides baseTheme, + LocalBaseUIColors provides baseTheme.colors, + ) { content() } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index fb1c63fe..a55f1c85 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement import dev.krtirtho.spotube.core.systemtray.SystemTrayService +import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.FluentMaximize @@ -63,6 +64,7 @@ actual fun ApplicationMainBar( backButton: Boolean, transparent: Boolean, ) { + val colors = LocalBaseUIColors.current Box( modifier = Modifier.fillMaxWidth() .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize) @@ -82,9 +84,19 @@ actual fun ApplicationMainBar( TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent, + titleContentColor = colors.onSurface, + actionIconContentColor = colors.onSurfaceVariant, + navigationIconContentColor = colors.onSurfaceVariant, ) } else { - TopAppBarDefaults.topAppBarColors() + // The app bar follows the selected surface theme rather than Material's. + TopAppBarDefaults.topAppBarColors( + containerColor = colors.surface, + scrolledContainerColor = colors.surfaceContainer, + titleContentColor = colors.onSurface, + actionIconContentColor = colors.onSurfaceVariant, + navigationIconContentColor = colors.onSurfaceVariant, + ) }, modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons ) diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt index e38e6397..bc56ea24 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt @@ -17,26 +17,43 @@ package dev.krtirtho.spotube.core.ui.component +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.ScrollbarStyle import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors @Composable actual fun VerticalScrollbar( listState: LazyListState, modifier: Modifier ) { - VerticalScrollbar( - adapter = rememberScrollbarAdapter(listState), - modifier = modifier.fillMaxHeight() - .padding(end = 2.dp) - .padding(vertical = 16.dp) - .width(12.dp), - ) + val colors = LocalBaseUIColors.current + CompositionLocalProvider( + LocalScrollbarStyle provides ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = 300, + unhoverColor = colors.onSurface.copy(alpha = 0.18f), + hoverColor = colors.onSurface.copy(alpha = 0.4f), + ), + ) { + VerticalScrollbar( + adapter = rememberScrollbarAdapter(listState), + modifier = modifier.fillMaxHeight() + .padding(end = 2.dp) + .padding(vertical = 16.dp) + .width(12.dp), + ) + } } \ No newline at end of file From 963b360a81391fdbdfa15f8744c5873db2f34030 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 9 Oct 2026 14:51:30 +0600 Subject: [PATCH 036/132] feat(ui): new custom surface integration on all components --- .../composeResources/values/strings.xml | 8 + .../kotlin/dev/krtirtho/spotube/App.kt | 13 +- .../spotube/core/ui/base/BaseUIColorScheme.kt | 105 ++- .../krtirtho/spotube/core/ui/base/Buttons.kt | 30 +- .../dev/krtirtho/spotube/core/ui/base/Card.kt | 1 + .../krtirtho/spotube/core/ui/base/CheckBox.kt | 3 +- .../krtirtho/spotube/core/ui/base/ChipTabs.kt | 3 +- .../krtirtho/spotube/core/ui/base/Scaffold.kt | 27 +- .../krtirtho/spotube/core/ui/base/Surface.kt | 747 +++++++++++------- .../krtirtho/spotube/core/ui/base/Theme.kt | 91 ++- .../krtirtho/spotube/core/ui/base/Toggle.kt | 3 +- .../spotube/core/ui/theming/AppTheme.kt | 58 +- .../modules/settings/SurfaceAccents.kt | 70 ++ .../settings/sections/AppearanceSection.kt | 411 ++++++---- .../spotube/modules/shell/AppBottombar.kt | 10 +- .../modules/shell/AppFloatingPlayer.kt | 11 +- .../spotube/modules/shell/AppLargePlayer.kt | 7 +- .../spotube/modules/shell/AppSidebar.kt | 5 +- 18 files changed, 1065 insertions(+), 538 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 81f11912..b82b5562 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -68,6 +68,14 @@ Midnight Blue Metallic Slate + Look & feel + Surface material and accent. Current: %1$s · %2$s + Look & feel + Pick a surface material, then an accent that pairs with it. Every option is a curated combination. + Surface + Accent + Secondary + Download folder override Current folder: %1$s Current folder: default music folder diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 297f3998..648be9af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,11 +22,13 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay @@ -157,7 +159,16 @@ fun App( } SpotubeTheme(settings = userSettings) { - val baseUITheme = rememberBaseUITheme(surfaceTheme = userSettings.surfaceTheme) + val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val accent = if (isDark) { + userSettings.accentColor.toDarkColor() + } else { + userSettings.accentColor.toLightColor() + } + val baseUITheme = rememberBaseUITheme( + surfaceTheme = userSettings.surfaceTheme, + accentColor = accent, + ) CompositionLocalProvider( LocalBaseUITheme provides baseUITheme, LocalBaseUIColors provides baseUITheme.colors, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt index 412c97b4..cc588b44 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt @@ -55,6 +55,18 @@ data class BaseUIColorScheme( val onPrimaryContainer: Color, val secondaryContainer: Color, val onSecondaryContainer: Color, + val backgroundPrimary: Color, + val onBackgroundPrimary: Color, + val backgroundSecondary: Color, + val onBackgroundSecondary: Color, + val accentPrimary: Color, + val onAccentPrimary: Color, + val accentSecondary: Color, + val onAccentSecondary: Color, + val backgroundInverse: Color, + val onBackgroundInverse: Color, + val actionInverse: Color, + val onActionInverse: Color, val outline: Color, val outlineVariant: Color, val error: Color, @@ -79,12 +91,52 @@ fun rememberBaseUIColorScheme( accentColor: Color? = null, ): BaseUIColorScheme { val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val accent = accentColor ?: MaterialTheme.colorScheme.primary + val accent = accentColor ?: surfaceTheme.accentColor(isLight) return remember(surfaceTheme, isLight, accent) { baseUIColorScheme(surfaceTheme, isLight, accent) } } +/** + * The default accent colour that characterises a surface theme. Every theme has its own, + * so a Watery app is aqua and a Chrome app is steel, unless an accent is passed explicitly. + */ +fun SurfaceStyle.accentColor(isLight: Boolean): Color = when (this) { + SurfaceStyle.Watery -> if (isLight) Color(0xFF0E8FD6) else Color(0xFF4FC3F7) + SurfaceStyle.Icy -> if (isLight) Color(0xFF3FA9E0) else Color(0xFF7FD8FF) + SurfaceStyle.Chrome -> if (isLight) Color(0xFF5B6B7C) else Color(0xFF9AA5B4) + SurfaceStyle.Acrylic -> if (isLight) Color(0xFF5C6BC0) else Color(0xFF8C9EFF) + SurfaceStyle.Glass -> if (isLight) Color(0xFF7C4DFF) else Color(0xFFB388FF) +} + +/** + * The secondary accent is derived from the primary accent as a complementary hue, so it always + * shifts with — and harmonises with — the chosen accent instead of being fixed per style. + */ +private fun Color.complementaryAccent(hueShift: Float = 180f): Color { + val (hue, saturation, value) = toHsv() + return Color.hsv( + (hue + hueShift + 360f) % 360f, + saturation.coerceIn(0.45f, 0.9f), + value.coerceIn(0.45f, 0.95f), + ) +} + +private fun Color.toHsv(): Triple { + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + val delta = max - min + val rawHue = when { + delta == 0f -> 0f + max == red -> 60f * (((green - blue) / delta) % 6f) + max == green -> 60f * (((blue - red) / delta) + 2f) + else -> 60f * (((red - green) / delta) + 4f) + } + val hue = if (rawHue < 0f) rawHue + 360f else rawHue + val saturation = if (max == 0f) 0f else delta / max + return Triple(hue, saturation, max) +} + fun baseUIColorScheme( surfaceTheme: SurfaceStyle, isLight: Boolean, @@ -99,6 +151,21 @@ fun baseUIColorScheme( lerp(accent, Color.White, 0.7f) } val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f) + val inversePalette = surfacePalette(surfaceTheme, !isLight) + val secondaryAccent = accent.complementaryAccent() + val onSecondaryAccent = if (secondaryAccent.luminance() > 0.5f) Color(0xFF10161C) else Color.White + val secondaryBackground = if (isLight) { + lerp(palette.surface, secondaryAccent, 0.14f) + } else { + lerp(palette.surface, secondaryAccent, 0.22f) + } + val onSecondaryBackground = if (secondaryBackground.luminance() > 0.5f) { + lerp(secondaryAccent, Color.Black, 0.6f) + } else { + lerp(secondaryAccent, Color.White, 0.82f) + } + val actionInverse = if (isLight) lerp(accent, Color.Black, 0.45f) else lerp(accent, Color.White, 0.5f) + val onActionInverse = if (actionInverse.luminance() > 0.5f) Color(0xFF10161C) else Color.White return BaseUIColorScheme( surfaceTheme = surfaceTheme, @@ -120,6 +187,18 @@ fun baseUIColorScheme( onPrimaryContainer = onPrimaryContainer, secondaryContainer = secondaryContainer, onSecondaryContainer = palette.onSurface, + backgroundPrimary = palette.surface, + onBackgroundPrimary = palette.onSurface, + backgroundSecondary = secondaryBackground, + onBackgroundSecondary = onSecondaryBackground, + accentPrimary = accent, + onAccentPrimary = onPrimary, + accentSecondary = secondaryAccent, + onAccentSecondary = onSecondaryAccent, + backgroundInverse = inversePalette.surface, + onBackgroundInverse = inversePalette.onSurface, + actionInverse = actionInverse, + onActionInverse = onActionInverse, outline = palette.outline, outlineVariant = palette.outlineVariant, error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373), @@ -128,7 +207,7 @@ fun baseUIColorScheme( ) } -private class SurfacePalette( +internal class SurfacePalette( val backgroundTop: Color, val backgroundBottom: Color, val surface: Color, @@ -143,7 +222,7 @@ private class SurfacePalette( val outlineVariant: Color, ) -private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { +internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { return if (isLight) { when (style) { SurfaceStyle.Glass -> SurfacePalette( @@ -177,18 +256,18 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett ) SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFFD6F0FF), - backgroundBottom = Color(0xFFB4DFFA), + backgroundTop = Color(0xFFEAF7FE), + backgroundBottom = Color(0xFFD8EFFB), surface = Color(0xFFF0FAFF), - surfaceVariant = Color(0xFFDCF0FC), - surfaceContainer = Color(0xFFE7F5FE), - surfaceContainerHigh = Color(0xFFEFF9FF), - surfaceContainerHighest = Color(0xFFCFE9F8), + surfaceVariant = Color(0xFFE2F2FB), + surfaceContainer = Color(0xFFEAF6FD), + surfaceContainerHigh = Color(0xFFF2FAFE), + surfaceContainerHighest = Color(0xFFD8ECF8), onBackground = Color(0xFF08324A), onSurface = Color(0xFF08324A), onSurfaceVariant = Color(0xFF3A6884), - outline = Color(0xFF8FBBD4), - outlineVariant = Color(0xFFC6E2F2), + outline = Color(0xFF9EC4DA), + outlineVariant = Color(0xFFD0E7F4), ) SurfaceStyle.Icy -> SurfacePalette( @@ -254,8 +333,8 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett ) SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFF06121E), - backgroundBottom = Color(0xFF0B1F31), + backgroundTop = Color(0xFF0A1622), + backgroundBottom = Color(0xFF0E1E2D), surface = Color(0xFF0E2233), surfaceVariant = Color(0xFF153148), surfaceContainer = Color(0xFF0B1D2C), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index 430b2899..daccca41 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -138,6 +138,27 @@ private fun Modifier.applyShadow( } } +/** + * Overlays the ambient [SurfaceStyle] material for a colour's [BaseUITheme.ButtonColors.surfaceRole], + * so an actionable item looks exactly like the matching [BaseSurface] role. + */ +@Composable +internal fun Modifier.applySurfaceMaterial(colors: BaseUITheme.ButtonColors): Modifier = + applySurfaceMaterial(colors.surfaceRole) + +@Composable +internal fun Modifier.applySurfaceMaterial(role: SurfaceRole?): Modifier { + val scheme = LocalBaseUIColors.current + return role?.let { baseSurfaceTexture(scheme.surfaceTheme, it, scheme.isLight) } ?: this +} + +/** + * The generic button gloss. Textured items get their sheen from the surface material, so it + * is only applied to colours without a [BaseUITheme.ButtonColors.surfaceRole]. + */ +internal fun Modifier.applyHighlight(colors: BaseUITheme.ButtonColors): Modifier = + if (colors.surfaceRole == null) highlight(colors.highlight) else this + @Composable fun OutlineButton( onClick: () -> Unit, @@ -172,6 +193,7 @@ fun OutlineButton( state.colors.background, state.shape ) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) ) .clickable( @@ -180,7 +202,7 @@ fun OutlineButton( indication = ripple(), onClick = onClick, ) - .then(if (hoverOnly && !isHovered) Modifier else Modifier.highlight(state.colors.highlight)) + .then(if (hoverOnly && !isHovered) Modifier else Modifier.applyHighlight(state.colors)) .padding(state.padding), contentAlignment = Alignment.Center, ) { @@ -221,6 +243,7 @@ fun PrimaryButton( .applyShadow(state.shadow, state.shape) .clip(state.shape) .background(state.colors.background, state.shape) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) .clickable( enabled = enabled, @@ -228,7 +251,7 @@ fun PrimaryButton( indication = ripple(), onClick = onClick, ) - .highlight(state.colors.highlight) + .applyHighlight(state.colors) .padding(state.padding), contentAlignment = Alignment.Center, ) { @@ -267,6 +290,7 @@ fun SecondaryButton( .applyShadow(state.shadow, state.shape) .clip(state.shape) .background(state.colors.background, state.shape) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) .clickable( enabled = enabled, @@ -274,7 +298,7 @@ fun SecondaryButton( indication = ripple(), onClick = onClick, ) - .highlight(state.colors.highlight) + .applyHighlight(state.colors) .padding(state.padding), contentAlignment = Alignment.Center, ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt index cdb7f54e..91802df8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt @@ -75,6 +75,7 @@ fun Card( ) .clip(cardTheme.shape) .background(cardTheme.background, cardTheme.shape) + .applySurfaceMaterial(cardTheme.surfaceRole) .border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape) .padding(cardTheme.padding), ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt index af1f0643..b037e565 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt @@ -146,8 +146,9 @@ fun CheckBox( ) .clip(resolved.shape) .background(resolved.colors.background, resolved.shape) + .applySurfaceMaterial(resolved.colors) .border(BorderStroke(0.5.dp, borderColor), resolved.shape) - .highlight(resolved.colors.highlight) + .applyHighlight(resolved.colors) .then( if (onClick != null) { Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt index e8042ee0..98a34762 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt @@ -123,6 +123,7 @@ fun ChipTab( .applyChipShadow(state.shadow, state.shape) .clip(state.shape) .background(state.colors.background, state.shape) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) .clickable( enabled = enabled, @@ -130,7 +131,7 @@ fun ChipTab( indication = ripple(), onClick = onClick, ) - .highlight(state.colors.highlight) + .applyHighlight(state.colors) .padding(state.padding), contentAlignment = Alignment.Center, ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt index 59a6ea61..70ffe90f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt @@ -39,12 +39,14 @@ import androidx.compose.ui.unit.dp /** * A theme-aware replacement for [Scaffold]. * - * Material's [Scaffold] paints Material's own surface colour, which hides the selected - * [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead: + * A scaffold is always the current theme's **Primary Background** — there is no option to + * switch its surface. Material's [Scaffold] would paint Material's own surface colour and + * hide the selected [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass), so [BaseScaffold] + * paints the theme's primary background material instead. * - * - is **transparent by default**, so the app-wide themed backdrop shows through, or - * - paints a chosen [surface] material locally, or - * - uses an explicit [containerColor] when one is given. + * [containerColor] is a narrow escape hatch for the rare screen that paints its own + * backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a + * surface-style switch. * * It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host, * floating action button) and hands the resolved [PaddingValues] to [content]. @@ -56,7 +58,6 @@ fun BaseScaffold( bottomBar: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, - surface: SurfaceStyle? = null, containerColor: Color? = null, contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, content: @Composable (PaddingValues) -> Unit, @@ -70,15 +71,9 @@ fun BaseScaffold( containerColor = Color.Transparent, contentWindowInsets = contentWindowInsets, ) { innerPadding -> - val backgroundModifier = when { - containerColor != null -> Modifier.background(containerColor) - surface != null -> { - val isLight = LocalBaseUIColors.current.isLight - Modifier.baseSurfaceBackdrop(surface, isLight) - } - - else -> Modifier - } + val colors = LocalBaseUIColors.current + val backgroundModifier = containerColor?.let { Modifier.background(it) } + ?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight) Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) { content(innerPadding) } @@ -90,9 +85,7 @@ fun BaseScaffold( @Composable private fun BaseScaffoldPreview() { BaseUIPreview { - val style = LocalBaseUITheme.current.surfaceTheme BaseScaffold( - surface = style, topBar = { Text( text = "Title", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt index f10ae714..d7c7b366 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,7 +49,8 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -76,7 +78,31 @@ enum class SurfaceStyle { } /** - * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials. + * The role a surface plays within a theme. Each style produces a distinct material for + * every role, so a theme is composed from them: + * + * - [PrimaryBackground] — the subtle main background (pages, panels). + * - [SecondaryBackground] — a distinct, complementary background hue that pairs with the + * primary (grouped/alternate sections). + * - [PrimaryAccent] — the primary accent surface for interactive elements (buttons, + * toggles, active chips). + * - [SecondaryAccent] — a complementary accent surface (secondary actions, highlights). + * - [InverseBackground] — the inverted-brightness background (tooltips, snackbars). + * - [InverseAction] — the inverted-brightness action surface. + */ +@Serializable +enum class SurfaceRole { + PrimaryBackground, + SecondaryBackground, + PrimaryAccent, + SecondaryAccent, + InverseBackground, + InverseAction, +} + +/** + * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials, + * in the given [SurfaceRole]. * * Distinct from [androidx.compose.material3.Surface] — this draws decorative material * backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching @@ -85,7 +111,8 @@ enum class SurfaceStyle { @Composable fun BaseSurface( modifier: Modifier = Modifier, - style: SurfaceStyle = SurfaceStyle.Glass, + style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, + role: SurfaceRole = SurfaceRole.PrimaryBackground, shape: Shape = RectangleShape, elevation: Dp? = null, contentColor: Color? = null, @@ -93,63 +120,60 @@ fun BaseSurface( contentAlignment: Alignment = Alignment.TopStart, content: @Composable BoxScope.() -> Unit, ) { - val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val onSurface = MaterialTheme.colorScheme.onSurface + val ambientScheme = LocalBaseUIColors.current + val isLight = ambientScheme.isLight + // A surface is rendered in its own style's colour scheme, so previewing a style shows + // that style's real accent instead of the ambient theme's. When the requested style is + // the ambient theme, the ambient scheme is used so a user-chosen accent still applies. + val ownScheme = remember(style, isLight) { + baseUIColorScheme(style, isLight, style.accentColor(isLight)) + } + val scheme = if (ambientScheme.surfaceTheme == style) ambientScheme else ownScheme + val palette = surfacePalette(style, isLight) - val resolvedElevation = elevation ?: when (style) { + val baseElevation = when (style) { SurfaceStyle.Watery -> 14.dp SurfaceStyle.Icy -> 12.dp SurfaceStyle.Chrome -> 10.dp SurfaceStyle.Acrylic -> 16.dp SurfaceStyle.Glass -> 12.dp } - val resolvedContentColor = contentColor ?: when (style) { - SurfaceStyle.Watery -> Color.White - SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7) - SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2) - SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface + val resolvedElevation = elevation ?: when (role) { + SurfaceRole.PrimaryBackground -> baseElevation + SurfaceRole.SecondaryBackground -> baseElevation + 2.dp + SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp + SurfaceRole.InverseBackground -> baseElevation + SurfaceRole.InverseAction -> baseElevation + 6.dp } - val ambient = when (style) { - SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f) - SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f) - SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f) - SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f) - SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f) + val resolvedContentColor = contentColor ?: when (role) { + SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary + SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary + SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary + SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary + SurfaceRole.InverseBackground -> scheme.onBackgroundInverse + SurfaceRole.InverseAction -> scheme.onActionInverse } - val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f)) - - val borderBrush = when (style) { - SurfaceStyle.Watery -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Icy -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)), - ) - - SurfaceStyle.Chrome -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)), - ) - - SurfaceStyle.Acrylic -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Glass -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)), - ) + val ambient = when (role) { + SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f) + SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f) + SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f) + SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f) + else -> palette.onSurface.copy(alpha = 0.2f) } + val spot = ambient.copy(alpha = (ambient.alpha + 0.07f).coerceAtMost(1f)) + val borderBrush = roleBorderBrush(style, role, isLight, scheme) // Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn // behind the node and, because the panel is see-through, shows up as a dark inset - // shape. Their depth comes from the edge, sheen and bevel instead. - val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic + // shape. Accent and secondary/inverse roles are filled, so they can cast one. + val translucent = role == SurfaceRole.PrimaryBackground && + (style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic) CompositionLocalProvider(LocalContentColor provides resolvedContentColor) { Box( modifier = modifier .then( - if (castsShadow) { + if (!translucent) { Modifier.shadow( elevation = resolvedElevation, shape = shape, @@ -162,7 +186,7 @@ fun BaseSurface( ) .clip(shape) .drawWithCache { - onDrawBehind { drawSurfaceMaterial(style, isLight) } + onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) } } .border(BorderStroke(1.dp, borderBrush), shape) .padding(contentPadding), @@ -173,199 +197,313 @@ fun BaseSurface( } /** - * Paints the full-bleed material of [style] as a page background: the material body plus - * its own sheen and reflections, but no panel bevel (a backdrop has no edges). + * Paints the full-bleed material of [style] as a page background: the primary material + * body plus its sheen and reflections, but no panel bevel (a backdrop has no edges). */ fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier = drawWithCache { - onDrawBehind { drawSurfaceBase(style, isLight) } - } - -private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) { - drawSurfaceBase(style, isLight) - drawInnerBevel(isLight) -} - -private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) { - when (style) { - SurfaceStyle.Watery -> drawWatery(isLight) - SurfaceStyle.Icy -> drawIcy(isLight) - SurfaceStyle.Chrome -> drawChrome(isLight) - SurfaceStyle.Acrylic -> drawAcrylic(isLight) - SurfaceStyle.Glass -> drawGlass(isLight) - } -} - -private fun DrawScope.drawGlass(isLight: Boolean) { - // See-through glass: a faint blue-tinted fill so whatever is behind shows through, - // defined by its bright edge, sheen and shadow rather than an opaque body. - val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.4f else 0.2f), - tint.copy(alpha = if (isLight) 0.24f else 0.1f), - ), - ), - ) - drawGloss(if (isLight) 0.55f else 0.16f) - drawBottomReflection(if (isLight) 0.4f else 0.1f) - drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) -} - -private fun DrawScope.drawAcrylic(isLight: Boolean) { - // See-through acrylic: a translucent tinted sheet with a fine grain. - val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.45f else 0.22f), - tint.copy(alpha = if (isLight) 0.28f else 0.12f), - ), - ), - ) - - // Fine grain so the panel reads as a translucent acrylic sheet. - val random = Random(23) - repeat(180) { - val light = random.nextBoolean() - drawCircle( - color = if (light) { - Color.White.copy(alpha = if (isLight) 0.16f else 0.06f) - } else { - Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f) - }, - radius = 0.7f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) - } - drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) -} - -private fun DrawScope.drawChrome(isLight: Boolean) { - if (isLight) { - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFFF8FBFF), - 0.16f to Color(0xFFD6DEE7), - 0.4f to Color(0xFF8E99A8), - 0.5f to Color(0xFFEDF2F7), - 0.62f to Color(0xFF9AA5B4), - 1f to Color(0xFF6C7787), - ), - ) - // Hard specular reflection near the top. - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent), - startY = size.height * 0.05f, - endY = size.height * 0.22f, - ), - ) - } else { - // Dark gunmetal for the night variant. - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFF3B424C), - 0.16f to Color(0xFF2A303A), - 0.4f to Color(0xFF1B2028), - 0.5f to Color(0xFF343B45), - 0.62f to Color(0xFF232830), - 1f to Color(0xFF141820), - ), - ) - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = 0.18f), Color.Transparent), - startY = size.height * 0.05f, - endY = size.height * 0.22f, - ), - ) - } - // Darkened lower edge for the metal bevel. - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)), - startY = size.height * 0.78f, - endY = size.height, - ), - ) -} - -private fun DrawScope.drawIcy(isLight: Boolean) { - val colors = if (isLight) { - listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0)) - } else { - // Snowy-night ice: dim slate-blue with faint frost. - listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26)) - } - drawRect(brush = Brush.verticalGradient(colors)) - - val frost = if (isLight) Color.White else Color(0xFFB8CCE0) - - // Crystalline facets (kept inset so they are never clipped by the shape). - val random = Random(37) - repeat(4) { - val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f) - val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f) - val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f) - val path = Path().apply { - moveTo(x, y) - lineTo(x + s, y + s * 0.4f) - lineTo(x + s * 0.4f, y + s) - close() + onDrawBehind { + drawStyleBase(style, isLight) + drawStyleDecorations(style, isLight) } - drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f)) } - // Frost speckles. - repeat(90) { - drawCircle( - color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), - radius = 0.8f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) +/** + * Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band, + * grain) and its inner bevel on a node that already carries a base fill. Compact components + * — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role] + * without redrawing the fill (which comes from their own interaction-state brush). + */ +fun Modifier.baseSurfaceTexture( + style: SurfaceStyle, + role: SurfaceRole, + isLight: Boolean, +): Modifier = drawWithCache { + onDrawBehind { + val effectiveLight = when (role) { + SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight + else -> isLight + } + drawStyleDecorations(style, effectiveLight) + drawInnerBevel(effectiveLight) } - drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f) } -private fun DrawScope.drawWatery(isLight: Boolean) { - val colors = if (isLight) { - listOf(Color(0xFFA6E3FF), Color(0xFF3FB6EA), Color(0xFF0E6FB0)) - } else { - // Moonlit night water: deep blue with a pale silver sheen. - listOf(Color(0xFF1C3A5E), Color(0xFF102942), Color(0xFF071827)) +/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */ +@Composable +fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier { + val scheme = LocalBaseUIColors.current + return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight) +} + +/** + * Paints the full [SurfaceStyle] material for [role] — base fill, material character and inner + * bevel — using the ambient [BaseUIColorScheme]. Use it to make a chrome panel (a sidebar, a + * player bar) a first-class surface rather than a flat Material surface. + */ +@Composable +fun Modifier.baseSurfaceMaterial( + role: SurfaceRole = SurfaceRole.PrimaryBackground, + style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, +): Modifier { + val scheme = LocalBaseUIColors.current + return drawWithCache { + onDrawBehind { drawSurfaceMaterial(style, role, scheme.isLight, scheme) } } - drawRect(brush = Brush.verticalGradient(colors)) +} - val sheen = if (isLight) Color.White else Color(0xFFB9D2F0) +private fun DrawScope.drawSurfaceMaterial( + style: SurfaceStyle, + role: SurfaceRole, + isLight: Boolean, + scheme: BaseUIColorScheme, +) { + // Role base fill. + when (role) { + SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight) - // Caustic / moonlight pooling from the surface. + // A distinct, complementary background hue (keeps the style's texture below). + SurfaceRole.SecondaryBackground -> drawRect(color = scheme.backgroundSecondary) + + SurfaceRole.PrimaryAccent -> drawAccentBase(scheme.accentPrimary) + SurfaceRole.SecondaryAccent -> drawAccentBase(scheme.accentSecondary) + + // Opaque inverted background so translucent styles still read as a solid surface. + SurfaceRole.InverseBackground -> drawRect(color = scheme.backgroundInverse) + + SurfaceRole.InverseAction -> drawAccentBase(scheme.actionInverse) + } + + // The material's own character (bubbles, facets, grain, specular band, sheen). + val effectiveLight = when (role) { + SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight + else -> isLight + } + drawStyleDecorations(style, effectiveLight) + drawInnerBevel(effectiveLight) +} + +// --- Material base gradients (one per style) --------------------------------- + +private fun DrawScope.drawStyleBase(style: SurfaceStyle, isLight: Boolean) { + when (style) { + SurfaceStyle.Glass -> { + val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) + drawRect( + brush = Brush.verticalGradient( + listOf( + tint.copy(alpha = if (isLight) 0.4f else 0.2f), + tint.copy(alpha = if (isLight) 0.24f else 0.1f), + ), + ), + ) + } + + SurfaceStyle.Acrylic -> { + val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8) + drawRect( + brush = Brush.verticalGradient( + listOf( + tint.copy(alpha = if (isLight) 0.45f else 0.22f), + tint.copy(alpha = if (isLight) 0.28f else 0.12f), + ), + ), + ) + } + + SurfaceStyle.Chrome -> { + if (isLight) { + drawRect( + brush = Brush.verticalGradient( + 0f to Color(0xFFF8FBFF), + 0.16f to Color(0xFFD6DEE7), + 0.4f to Color(0xFF8E99A8), + 0.5f to Color(0xFFEDF2F7), + 0.62f to Color(0xFF9AA5B4), + 1f to Color(0xFF6C7787), + ), + ) + } else { + drawRect( + brush = Brush.verticalGradient( + 0f to Color(0xFF3B424C), + 0.16f to Color(0xFF2A303A), + 0.4f to Color(0xFF1B2028), + 0.5f to Color(0xFF343B45), + 0.62f to Color(0xFF232830), + 1f to Color(0xFF141820), + ), + ) + } + } + + SurfaceStyle.Icy -> { + val colors = if (isLight) { + listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0)) + } else { + listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26)) + } + drawRect(brush = Brush.verticalGradient(colors)) + } + + SurfaceStyle.Watery -> { + // A clear-but-soft aqua column: unmistakably water, without a heavy flat blue. + val colors = if (isLight) { + listOf(Color(0xFFD8F0FD), Color(0xFFB0DEF7), Color(0xFF83C8EE)) + } else { + listOf(Color(0xFF123249), Color(0xFF0B2135), Color(0xFF06141F)) + } + drawRect(brush = Brush.verticalGradient(colors)) + } + } +} + +/** The accent-filled base used by [SurfaceRole.Action]. */ +private fun DrawScope.drawAccentBase(accent: Color) { drawRect( brush = Brush.verticalGradient( - colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent), - startY = 0f, - endY = size.height * 0.5f, + listOf( + lerp(accent, Color.White, 0.45f), + accent, + lerp(accent, Color.Black, 0.16f), + ), ), ) +} - val random = Random(53) - repeat(5) { - val rx = size.width * (0.12f + random.nextFloat() * 0.2f) - val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) - val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f) - drawOval( - brush = Brush.radialGradient( - colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent), - center = Offset(cx, cy), - radius = rx, - ), - topLeft = Offset(cx - rx, cy - rx * 0.5f), - size = Size(rx * 2f, rx), - ) +// --- Material decorations (one per style) ------------------------------------ + +private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) { + when (style) { + SurfaceStyle.Glass -> { + drawGloss(if (isLight) 0.55f else 0.16f) + drawBottomReflection(if (isLight) 0.4f else 0.1f) + drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) + } + + SurfaceStyle.Acrylic -> { + // Fine grain so the panel reads as a translucent acrylic sheet. + val random = Random(23) + repeat(180) { + val light = random.nextBoolean() + drawCircle( + color = if (light) { + Color.White.copy(alpha = if (isLight) 0.16f else 0.06f) + } else { + Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f) + }, + radius = 0.7f, + center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), + ) + } + drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) + } + + SurfaceStyle.Chrome -> { + // Hard specular reflection near the top. + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = if (isLight) 0.85f else 0.18f), + Color.Transparent, + ), + startY = size.height * 0.05f, + endY = size.height * 0.22f, + ), + ) + // Darkened lower edge for the metal bevel. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)), + startY = size.height * 0.78f, + endY = size.height, + ), + ) + } + + SurfaceStyle.Icy -> { + val frost = if (isLight) Color.White else Color(0xFFB8CCE0) + + // Crystalline facets (kept inset so they are never clipped by the shape). + val random = Random(37) + repeat(4) { + val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f) + val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f) + val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f) + val path = Path().apply { + moveTo(x, y) + lineTo(x + s, y + s * 0.4f) + lineTo(x + s * 0.4f, y + s) + close() + } + drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f)) + } + + // Frost speckles. + repeat(90) { + drawCircle( + color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), + radius = 0.8f, + center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), + ) + } + drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f) + } + + SurfaceStyle.Watery -> { + val sheen = if (isLight) Color.White else Color(0xFFAFD3F2) + + // Caustic light pooling from the surface. + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + sheen.copy(alpha = if (isLight) 0.6f else 0.2f), + Color.Transparent, + ), + startY = 0f, + endY = size.height * 0.55f, + ), + ) + + val random = Random(53) + // Refracted caustic blobs — the bright, rippling patterns of moving water. + repeat(9) { + val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f) + val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) + val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f) + drawOval( + brush = Brush.radialGradient( + colors = listOf( + sheen.copy(alpha = if (isLight) 0.5f else 0.22f), + Color.Transparent, + ), + center = Offset(cx, cy), + radius = rx, + ), + topLeft = Offset(cx - rx, cy - rx * 0.7f), + size = Size(rx * 2f, rx * 1.4f), + ) + } + + // Ripple crests: thin horizontal highlights that read as surface waves. + repeat(3) { + val y = size.height * (0.18f + random.nextFloat() * 0.64f) + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + sheen.copy(alpha = if (isLight) 0.4f else 0.16f), + Color.Transparent, + ), + startY = y - size.height * 0.035f, + endY = y + size.height * 0.035f, + ), + ) + } + + drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true) + } } - - drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f) } private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) { @@ -406,7 +544,7 @@ private fun DrawScope.drawInnerBevel(isLight: Boolean) { ) } -private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) { +private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) { val random = Random(seed) repeat(count) { val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f) @@ -423,59 +561,97 @@ private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) { radius = radius, center = center, ) - } -} - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun BaseSurfacePreview() { - BaseUIPreview { - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - SurfaceStyle.entries.forEach { style -> - BaseSurface( - style = style, - modifier = Modifier - .fillMaxWidth() - .height(84.dp), - ) { - Text( - text = style.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - } + if (rim) { + drawCircle( + color = Color.White.copy(alpha = alpha * 0.7f), + radius = radius, + center = center, + style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)), + ) } } } -@BaseUIPhonePreview +private fun styleBorderBrush(style: SurfaceStyle): Brush = when (style) { + SurfaceStyle.Watery -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), + ) + + SurfaceStyle.Icy -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)), + ) + + SurfaceStyle.Chrome -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)), + ) + + SurfaceStyle.Acrylic -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)), + ) + + SurfaceStyle.Glass -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)), + ) +} + +private fun roleBorderBrush( + style: SurfaceStyle, + role: SurfaceRole, + isLight: Boolean, + scheme: BaseUIColorScheme, +): Brush = when (role) { + SurfaceRole.PrimaryBackground -> styleBorderBrush(style) + + SurfaceRole.SecondaryBackground -> Brush.verticalGradient( + listOf( + lerp(scheme.backgroundSecondary, Color.White, 0.5f) + .copy(alpha = if (isLight) 0.85f else 0.6f), + Color.White.copy(alpha = 0.12f), + ), + ) + + SurfaceRole.PrimaryAccent -> accentEdgeBrush(scheme.accentPrimary) + SurfaceRole.SecondaryAccent -> accentEdgeBrush(scheme.accentSecondary) + + SurfaceRole.InverseBackground -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.1f)), + ) + + SurfaceRole.InverseAction -> accentEdgeBrush(scheme.actionInverse) +} + +private fun accentEdgeBrush(accent: Color): Brush = Brush.verticalGradient( + listOf(lerp(accent, Color.White, 0.6f), lerp(accent, Color.Black, 0.2f)), +) + +/** + * One preview showing everything about surfaces: every [SurfaceStyle] crossed with every + * [SurfaceRole], plus the full-bleed backdrops — no need to scroll through many previews. + */ +@BaseUIDesktopPreview @Composable -private fun BaseSurfaceGridPreview() { +private fun SurfacePreview() { BaseUIPreview { + val colors = LocalBaseUIColors.current Column( modifier = Modifier .fillMaxSize() .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - SurfaceStyle.entries.chunked(2).forEach { rowStyles -> + SurfaceStyle.entries.forEach { style -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - rowStyles.forEach { style -> + SurfaceRole.entries.forEach { role -> BaseSurface( style = style, + role = role, modifier = Modifier .weight(1f) - .height(140.dp), + .height(84.dp), + contentPadding = PaddingValues(10.dp), ) { Column( modifier = Modifier.fillMaxSize(), @@ -483,54 +659,41 @@ private fun BaseSurfaceGridPreview() { ) { Text( text = style.name, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, ) Text( - text = "Frutiger Aero", - style = MaterialTheme.typography.labelMedium, + text = role.name, + style = MaterialTheme.typography.labelSmall, ) } } } - repeat(2 - rowStyles.size) { - androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f)) + } + } + + // Full-bleed backdrops (the page background material, without the panel bevel). + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SurfaceStyle.entries.forEach { style -> + Box( + modifier = Modifier + .weight(1f) + .height(60.dp) + .clip(RoundedCornerShape(12.dp)) + .baseSurfaceBackdrop(style, colors.isLight), + contentAlignment = Alignment.Center, + ) { + Text( + text = style.name, + style = MaterialTheme.typography.labelSmall, + color = colors.onSurface, + ) } } } } } } - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun SurfaceThemeBackdropPreview() { - val isLight = LocalBaseUIColors.current.isLight - val onSurface = LocalBaseUIColors.current.onSurface - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - SurfaceStyle.entries.forEach { style -> - Box( - modifier = Modifier - .fillMaxWidth() - .height(66.dp) - .clip(RoundedCornerShape(14.dp)) - .baseSurfaceBackdrop(style, isLight), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = style.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = if (style == SurfaceStyle.Watery) Color.White else onSurface, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt index 826a3319..33880cab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt @@ -124,6 +124,13 @@ data class BaseUITheme( val background: Brush, val foreground: Color, val highlight: Color, + /** + * The [SurfaceRole] this colour represents. When set, components overlay the + * matching [SurfaceStyle] material (gloss, bubbles, facets, specular band, grain) + * on top of [background], so buttons and other actionable items share the exact + * look of a [BaseSurface]. `null` means no material (e.g. ghost buttons). + */ + val surfaceRole: SurfaceRole? = null, ) data class CardTheme( @@ -132,6 +139,8 @@ data class BaseUITheme( val border: Border, val padding: PaddingValues, val shadow: Shadow, + /** The [SurfaceRole] the card renders, so it can overlay the matching material. */ + val surfaceRole: SurfaceRole? = null, ) data class ListRowTheme( @@ -232,16 +241,12 @@ fun rememberBaseUITheme( // highlight and translucent "glass" edges, lifted by soft, coloured // shadows — the aqua/glass language of Frutiger-Aero. // ------------------------------------------------------------------ - val glassTop = if (isLight) { - Color.White - } else { - lerp(colors.surfaceContainerHigh, Color.White, 0.10f) - } - val glassBottom = if (isLight) { - lerp(colors.surface, Color.White, 0.04f) - } else { - colors.surfaceContainer - } + // Primary surface (subtle) with the style's palette, and the secondary surface + // (slightly accented) used by cards and grouped content. + val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f) + val panelBottom = colors.surfaceVariant + val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f) + val secondaryBottom = colors.backgroundSecondary val glassPressed = if (isLight) { lerp(colors.surfaceContainerHighest, Color.White, 0.06f) } else { @@ -292,12 +297,12 @@ fun rememberBaseUITheme( // --- Glass (outline) ------------------------------------------------- val outlineColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), foreground = colors.onSurface, highlight = gloss, ), hovered = BaseUITheme.ButtonColors( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), foreground = colors.onSurface, highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f), ), @@ -307,11 +312,11 @@ fun rememberBaseUITheme( highlight = glossSoft, ), focused = BaseUITheme.ButtonColors( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), foreground = colors.onSurface, highlight = gloss, ), - ) + ).withRole(SurfaceRole.PrimaryBackground) val glassShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 8.dp, @@ -348,10 +353,10 @@ fun rememberBaseUITheme( // --- Aqua accent (primary) ------------------------------------------- val primaryEdge = lerp(colors.primary, Color.White, 0.5f) val primaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss), + normal = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), hovered = BaseUITheme.ButtonColors( - aqua(colors.primary), - colors.onPrimary, + aqua(colors.accentPrimary), + colors.onAccentPrimary, Color.White.copy(alpha = if (isLight) 0.9f else 0.3f), ), pressed = BaseUITheme.ButtonColors( @@ -359,11 +364,11 @@ fun rememberBaseUITheme( lerp(colors.primary, Color.Black, 0.16f), lerp(colors.primary, Color.Black, 0.24f), ), - colors.onPrimary, + colors.onAccentPrimary, glossSoft, ), - focused = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss), - ) + focused = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), + ).withRole(SurfaceRole.PrimaryAccent) val primaryShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 10.dp, @@ -398,7 +403,7 @@ fun rememberBaseUITheme( ) // --- Secondary (soft aqua glass) ------------------------------------- - val secondaryBase = colors.secondaryContainer + val secondaryBase = colors.backgroundSecondary val secondaryColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), @@ -423,7 +428,7 @@ fun rememberBaseUITheme( colors.onSecondaryContainer, gloss, ), - ) + ).withRole(SurfaceRole.SecondaryBackground) val secondaryShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 8.dp, @@ -592,10 +597,10 @@ fun rememberBaseUITheme( ), textField = TextFieldTheme( background = BaseUITheme.InteractionState( - normal = glassBrush(glassTop, glassBottom), - hovered = glassBrush(glassTop, glassBottom), + normal = glassBrush(panelTop, panelBottom), + hovered = glassBrush(panelTop, panelBottom), pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(glassTop, glassBottom), + focused = glassBrush(panelTop, panelBottom), ), highlight = BaseUITheme.InteractionState( normal = gloss, @@ -647,7 +652,7 @@ fun rememberBaseUITheme( foreground = textFieldForeground, ), autoCompleteMenuTheme = AutoCompleteMenuTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), shape = RoundedCornerShape(16.dp), border = BaseUITheme.Border(glassBorder, 1.dp), padding = PaddingValues(0.dp), @@ -716,7 +721,7 @@ fun rememberBaseUITheme( border = glassBorderState, padding = noPadding, ), - checkmarkColor = colors.onPrimary, + checkmarkColor = colors.onAccentPrimary, ), chipTab = ChipTabTheme( selected = BaseUITheme.ButtonStyle( @@ -783,7 +788,7 @@ fun rememberBaseUITheme( ), ), card = BaseUITheme.CardTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(secondaryTop, secondaryBottom), shape = RoundedCornerShape(20.dp), border = BaseUITheme.Border(glassBorder, 1.dp), padding = PaddingValues(0.dp), @@ -793,20 +798,18 @@ fun rememberBaseUITheme( shadowColor.copy(alpha = 0.16f), shadowColor.copy(alpha = 0.2f) ), + surfaceRole = SurfaceRole.SecondaryBackground, ), listRowTile = BaseUITheme.ListRowTheme( background = BaseUITheme.AdvancedInteractionState( normal = glassBrush(Color.Transparent, Color.Transparent), - hovered = glassBrush(glassTop, glassBottom), + hovered = glassBrush(panelTop, panelBottom), pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(glassTop, glassBottom), - selected = glassBrush( - lerp(colors.primaryContainer, Color.White, 0.4f), - colors.primaryContainer, - ), + focused = glassBrush(panelTop, panelBottom), + selected = glassBrush(secondaryTop, secondaryBottom), disabled = glassBrush( - glassTop.copy(alpha = 0.5f), - glassBottom.copy(alpha = 0.5f), + panelTop.copy(alpha = 0.5f), + panelBottom.copy(alpha = 0.5f), ), ), shape = BaseUITheme.AdvancedInteractionState( @@ -857,7 +860,7 @@ fun rememberBaseUITheme( ), ), dialog = BaseUITheme.DialogTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), scrim = Color.Black.copy(alpha = 0.42f), shape = RoundedCornerShape(24.dp), border = BaseUITheme.Border(glassEdge, 1.dp), @@ -870,7 +873,7 @@ fun rememberBaseUITheme( padding = PaddingValues(24.dp), ), dropdownMenu = BaseUITheme.DropdownMenuTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), shape = RoundedCornerShape(16.dp), border = BaseUITheme.Border(glassBorder, 1.dp), shadow = BaseUITheme.Shadow( @@ -879,7 +882,7 @@ fun rememberBaseUITheme( shadowColor.copy(alpha = 0.22f), shadowColor.copy(alpha = 0.28f) ), - itemHoverBackground = glassBrush(glassTop, glassBottom), + itemHoverBackground = glassBrush(panelTop, panelBottom), itemForeground = colors.onSurface, itemHighlight = gloss, padding = PaddingValues(6.dp), @@ -905,6 +908,16 @@ fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.But fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle = copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors)) +/** Tags every interaction state of a button colour set with the surface role it renders. */ +private fun BaseUITheme.InteractionState.withRole( + role: SurfaceRole, +): BaseUITheme.InteractionState = BaseUITheme.InteractionState( + normal = normal.copy(surfaceRole = role), + hovered = hovered.copy(surfaceRole = role), + pressed = pressed.copy(surfaceRole = role), + focused = focused.copy(surfaceRole = role), +) + @Composable fun invertedButtonStyle(): BaseUITheme.ButtonStyle { val scheme = MaterialTheme.colorScheme diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt index ef3ddb83..607ef0cb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt @@ -184,8 +184,9 @@ fun Toggle( ) .clip(resolved.shape) .background(resolved.colors.background, resolved.shape) + .applySurfaceMaterial(resolved.colors) .border(BorderStroke(resolved.border.width, borderColor), resolved.shape) - .highlight(resolved.colors.highlight) + .applyHighlight(resolved.colors) .then( if (onCheckedChange != null) { Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index 1e5d69cc..633d1f96 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -18,13 +18,25 @@ package dev.krtirtho.spotube.core.ui.theming import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.materialkolor.rememberDynamicColorScheme +import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme +import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +/** + * The app's single source of truth for colour. + * + * The selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a + * [BaseUIColorScheme], and every Material role is then overridden from it. That way the + * whole app — Material components included — renders in the chosen surface theme's colours + * and accent, instead of Material's dynamic palette drifting away from the base UI. + */ @Composable fun SpotubeTheme( settings: UserSettings, @@ -35,6 +47,7 @@ fun SpotubeTheme( Theme.DARK -> true Theme.SYSTEM -> isSystemInDarkTheme() } + val isLight = !isDarkTheme val accent = if (isDarkTheme) { settings.accentColor.toDarkColor() @@ -42,15 +55,56 @@ fun SpotubeTheme( settings.accentColor.toLightColor() } - val colorScheme = rememberDynamicColorScheme( + val baseColors = remember(settings.surfaceTheme, isLight, accent) { + baseUIColorScheme(settings.surfaceTheme, isLight, accent) + } + + val dynamicScheme = rememberDynamicColorScheme( seedColor = accent, isDark = isDarkTheme, ) MaterialTheme( - colorScheme = colorScheme, + colorScheme = dynamicScheme.mergeWithBaseColors(baseColors), ) { Surface(content = content) } } +/** + * Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the + * base UI palette, so Material components share the exact same colours as the base + * components and the surface theme's primary/secondary backgrounds. + */ +private fun ColorScheme.mergeWithBaseColors(base: BaseUIColorScheme): ColorScheme = copy( + primary = base.accentPrimary, + onPrimary = base.onAccentPrimary, + primaryContainer = base.primaryContainer, + onPrimaryContainer = base.onPrimaryContainer, + inversePrimary = base.actionInverse, + secondary = base.accentSecondary, + onSecondary = base.onAccentSecondary, + secondaryContainer = base.secondaryContainer, + onSecondaryContainer = base.onSecondaryContainer, + background = base.surface, + onBackground = base.onBackground, + surface = base.surface, + onSurface = base.onSurface, + surfaceVariant = base.surfaceVariant, + onSurfaceVariant = base.onSurfaceVariant, + surfaceTint = base.accentPrimary, + surfaceBright = base.surfaceContainerHigh, + surfaceDim = base.surfaceContainerHighest, + surfaceContainerLowest = base.surface, + surfaceContainerLow = base.surfaceContainer, + surfaceContainer = base.surfaceContainer, + surfaceContainerHigh = base.surfaceContainerHigh, + surfaceContainerHighest = base.surfaceContainerHighest, + inverseSurface = base.backgroundInverse, + inverseOnSurface = base.onBackgroundInverse, + outline = base.outline, + outlineVariant = base.outlineVariant, + error = base.error, + onError = base.onError, + scrim = base.scrim, +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt new file mode 100644 index 00000000..489a8b9f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt @@ -0,0 +1,70 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.settings + +import androidx.compose.ui.graphics.Color +import dev.krtirtho.spotube.core.ui.base.SurfaceStyle + +/** + * Accent palettes that are known to pair well with each surface material. + * + * The appearance picker only offers these combinations, so a user can never end up with a + * clashing surface + accent pair — instead of the old behaviour where any accent could be + * applied to any surface. + */ +val SurfaceStyle.recommendedAccents: List + get() = when (this) { + SurfaceStyle.Watery -> listOf( + AccentColors.OCEANIC_CYAN, + AccentColors.MIDNIGHT_BLUE, + AccentColors.GREEN_GOBLIN, + AccentColors.SUNSET_ORANGE, + ) + + SurfaceStyle.Icy -> listOf( + AccentColors.MIDNIGHT_BLUE, + AccentColors.OCEANIC_CYAN, + AccentColors.ELECTRIC_VIOLET, + AccentColors.ROSE_GARDEN, + ) + + SurfaceStyle.Chrome -> listOf( + AccentColors.METALLIC_SLATE, + AccentColors.MIDNIGHT_BLUE, + AccentColors.SUNSET_ORANGE, + AccentColors.GREEN_GOBLIN, + ) + + SurfaceStyle.Acrylic -> listOf( + AccentColors.METALLIC_SLATE, + AccentColors.ELECTRIC_VIOLET, + AccentColors.OCEANIC_CYAN, + AccentColors.ROSE_GARDEN, + ) + + SurfaceStyle.Glass -> listOf( + AccentColors.GREEN_GOBLIN, + AccentColors.ELECTRIC_VIOLET, + AccentColors.OCEANIC_CYAN, + AccentColors.ROSE_GARDEN, + ) + } + +/** The concrete accent colour for the current light/dark mode. */ +fun AccentColors.colorFor(isLight: Boolean): Color = + if (isLight) toLightColor() else toDarkColor() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index 54331e6f..260cd7d3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -23,45 +23,48 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.Droplet -import compose.icons.feathericons.Monitor -import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.BaseSurface +import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.Radio +import dev.krtirtho.spotube.core.ui.base.SurfaceRole import dev.krtirtho.spotube.core.ui.base.SurfaceStyle +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme import dev.krtirtho.spotube.modules.settings.AccentColors import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.colorFor import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SettingCardItem +import dev.krtirtho.spotube.modules.settings.recommendedAccents import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch -import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* @@ -97,39 +100,23 @@ internal fun LazyListScope.appearanceSection( ) }, { - SelectionSettingCard( - title = stringResource(Res.string.settings_surface_theme_title), - subtitle = stringResource( - Res.string.settings_surface_theme_subtitle_current, - settings.surfaceTheme.displayLabel() - ), + // Surface material and accent are one decision: each surface only offers the + // accents that are known to pair with it, so the result is always a curated, + // good-looking combination instead of an arbitrary one. + AppearanceSettingCard( + selectedSurface = settings.surfaceTheme, + selectedAccent = settings.accentColor, icon = { SettingsItemIcon( Iconsax.IconsaxMagic, - stringResource(Res.string.settings_surface_theme_title) + stringResource(Res.string.settings_appearance_title) ) }, - selectedOption = settings.surfaceTheme, - options = SurfaceStyle.entries, - optionLabel = { it.displayLabel() }, - onOptionSelected = { surfaceTheme -> + onSaved = { surface, accent -> settingsViewModel.updateSettings { - copy(surfaceTheme = surfaceTheme) + copy(surfaceTheme = surface, accentColor = accent) } - } - ) - }, - { - AccentColorSettingCard( - selectedAccent = settings.accentColor, - icon = { - SettingsItemIcon(Iconsax.IconsaxColorsSquare, stringResource(Res.string.settings_accent_title)) }, - onColorSaved = { accent -> - settingsViewModel.updateSettings { - copy(accentColor = accent) - } - } ) }, ) @@ -170,43 +157,53 @@ private fun AccentColors.displayLabel(): String { } @Composable -private fun AccentColorSettingCard( +private fun AppearanceSettingCard( + selectedSurface: SurfaceStyle, selectedAccent: AccentColors, icon: (@Composable () -> Unit)? = null, - onColorSaved: (AccentColors) -> Unit, + onSaved: (SurfaceStyle, AccentColors) -> Unit, ) { var isDialogOpen by remember { mutableStateOf(false) } + val isLight = LocalBaseUIColors.current.isLight SettingCardItem( - title = stringResource(Res.string.settings_accent_title), + title = stringResource(Res.string.settings_appearance_title), subtitle = stringResource( - Res.string.settings_accent_subtitle_current, - selectedAccent.displayLabel() + Res.string.settings_appearance_subtitle_current, + selectedSurface.displayLabel(), + selectedAccent.displayLabel(), ), icon = icon, trailingContent = { - AccentDualPreview( - lightAccent = selectedAccent.toLightColor(), - darkAccent = selectedAccent.toDarkColor(), + AppearanceTrailingSwatch( + surface = selectedSurface, + accent = selectedAccent, + isLight = isLight, ) }, - onClick = { - isDialogOpen = true - } + onClick = { isDialogOpen = true }, ) if (isDialogOpen) { - var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) } + var draftSurface by remember(selectedSurface, isDialogOpen) { + mutableStateOf(selectedSurface) + } + var draftAccent by remember(selectedSurface, selectedAccent, isDialogOpen) { + mutableStateOf(selectedAccent) + } ThemedDialog( onDismissRequest = { isDialogOpen = false }, title = { - Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge) + Text( + stringResource(Res.string.settings_appearance_dialog_title), + style = MaterialTheme.typography.titleLarge, + ) }, actions = { PrimaryButton( onClick = { - onColorSaved(draftAccent) + onSaved(draftSurface, draftAccent) isDialogOpen = false } ) { @@ -217,141 +214,245 @@ private fun AccentColorSettingCard( } }, ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = stringResource(Res.string.settings_accent_dialog_description), + text = stringResource(Res.string.settings_appearance_dialog_description), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text( + text = stringResource(Res.string.settings_appearance_surface_label), + style = MaterialTheme.typography.labelLarge, + ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - AccentThemePreview( - title = stringResource(Res.string.settings_preview_light), - accent = draftAccent.toLightColor(), - background = Color(0xFFFFFFFF), - textColor = Color(0xFF121212), - modifier = Modifier.weight(1f) - ) - AccentThemePreview( - title = stringResource(Res.string.settings_preview_dark), - accent = draftAccent.toDarkColor(), - background = Color(0xFF121212), - textColor = Color(0xFFEDEDED), - modifier = Modifier.weight(1f) - ) + SurfaceStyle.entries.forEach { style -> + SurfaceStyleTile( + style = style, + accent = draftAccent, + isLight = isLight, + selected = style == draftSurface, + modifier = Modifier.weight(1f), + onClick = { + draftSurface = style + if (draftAccent !in style.recommendedAccents) { + draftAccent = style.recommendedAccents.first() + } + }, + ) + } } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - AccentColors.entries.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { draftAccent = option } - .padding(horizontal = 4.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Radio( - selected = option == draftAccent, - onClick = { draftAccent = option } - ) - Text( - text = option.displayLabel(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - AccentDualPreview( - lightAccent = option.toLightColor(), - darkAccent = option.toDarkColor(), - ) - } + Text( + text = stringResource(Res.string.settings_appearance_accent_label), + style = MaterialTheme.typography.labelLarge, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + draftSurface.recommendedAccents.forEach { accent -> + AccentSwatch( + accent = accent, + label = accent.displayLabel(), + isLight = isLight, + selected = accent == draftAccent, + onClick = { draftAccent = accent }, + ) } } + + AppearancePreview( + surface = draftSurface, + accent = draftAccent, + isLight = isLight, + ) + } + } + } +} + +@Composable +private fun SurfaceStyleTile( + style: SurfaceStyle, + accent: AccentColors, + isLight: Boolean, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val scheme = remember(style, accent, isLight) { + baseUIColorScheme(style, isLight, accent.colorFor(isLight)) + } + Box( + modifier = modifier + .height(70.dp) + .then( + if (selected) { + Modifier + .border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(14.dp)) + .padding(2.dp) + } else { + Modifier.padding(2.dp) + } + ), + ) { + CompositionLocalProvider(LocalBaseUIColors provides scheme) { + BaseSurface( + style = style, + role = SurfaceRole.PrimaryBackground, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + contentPadding = PaddingValues(8.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = style.displayLabel(), + style = MaterialTheme.typography.labelMedium, + ) + Box( + modifier = Modifier + .size(14.dp) + .background(accent.colorFor(isLight), CircleShape), + ) + } } } } } @Composable -private fun AccentDualPreview( - lightAccent: Color, - darkAccent: Color, +private fun AccentSwatch( + accent: AccentColors, + label: String, + isLight: Boolean, + selected: Boolean, + onClick: () -> Unit, ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box( - modifier = Modifier - .size(20.dp) - .background(Color.White, RoundedCornerShape(6.dp)) - .border(1.dp, Color(0x22000000), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(10.dp) - .background(lightAccent, CircleShape) + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(accent.colorFor(isLight)) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.outlineVariant + }, + shape = CircleShape, ) - } - Box( - modifier = Modifier - .size(20.dp) - .background(Color(0xFF121212), RoundedCornerShape(6.dp)) - .border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center + .clickable(onClick = onClick) + .semantics { contentDescription = label }, + ) +} + +@Composable +private fun AppearanceTrailingSwatch( + surface: SurfaceStyle, + accent: AccentColors, + isLight: Boolean, +) { + val scheme = remember(surface, accent, isLight) { + baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) + } + CompositionLocalProvider(LocalBaseUIColors provides scheme) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .size(10.dp) - .background(darkAccent, CircleShape) - ) + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryBackground, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.size(width = 46.dp, height = 28.dp), + ) {} + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryAccent, + shape = CircleShape, + modifier = Modifier.size(18.dp), + ) {} } } } @Composable -private fun AccentThemePreview( - title: String, - accent: Color, - background: Color, - textColor: Color, - modifier: Modifier = Modifier, +private fun AppearancePreview( + surface: SurfaceStyle, + accent: AccentColors, + isLight: Boolean, ) { - Column( - modifier = modifier - .background(background, RoundedCornerShape(10.dp)) - .border(1.dp, textColor.copy(alpha = 0.16f), RoundedCornerShape(10.dp)) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(title, style = MaterialTheme.typography.labelMedium, color = textColor) - Box( + val scheme = remember(surface, accent, isLight) { + baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) + } + CompositionLocalProvider(LocalBaseUIColors provides scheme) { + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryBackground, + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() - .height(6.dp) - .background(accent, RoundedCornerShape(999.dp)) - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + .height(150.dp), + contentPadding = PaddingValues(14.dp), ) { - Box( - modifier = Modifier - .size(14.dp) - .background(accent, CircleShape) - ) - Text( - stringResource(Res.string.settings_preview_label), - style = MaterialTheme.typography.bodySmall, - color = textColor - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - stringResource(Res.string.settings_preview_button), - style = MaterialTheme.typography.labelSmall, - color = accent - ) + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(Res.string.settings_preview_label), + style = MaterialTheme.typography.labelLarge, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryAccent, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + stringResource(Res.string.settings_preview_button), + style = MaterialTheme.typography.labelMedium, + ) + } + BaseSurface( + style = surface, + role = SurfaceRole.SecondaryAccent, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + stringResource(Res.string.settings_preview_secondary), + style = MaterialTheme.typography.labelMedium, + ) + } + } + BaseSurface( + style = surface, + role = SurfaceRole.SecondaryBackground, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(10.dp), + ) { + Text( + text = stringResource(Res.string.settings_preview_label), + style = MaterialTheme.typography.bodySmall, + ) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt index eaabb5e9..561704f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt @@ -47,6 +47,8 @@ import dev.krtirtho.spotube.bottombarTabs import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.ui.base.BaseSurface +import dev.krtirtho.spotube.core.ui.base.SurfaceRole import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator @Composable @@ -55,12 +57,10 @@ fun AppBottombar( navigationState: NavigationState, modifier: Modifier = Modifier, ) { - Surface( - modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), + BaseSurface( + role = SurfaceRole.PrimaryBackground, shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - shadowElevation = 10.dp, + modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), ) { Row( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt index 59178e07..b628831c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -70,8 +69,10 @@ import org.koin.core.parameter.parametersOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.ui.base.BaseUITheme +import dev.krtirtho.spotube.core.ui.base.BaseSurface import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.SurfaceRole // The floating player on small screens that appears above the floating AppBottombar // @@ -115,14 +116,12 @@ fun AppFloatingPlayer( } } - Surface( + BaseSurface( + role = SurfaceRole.InverseBackground, + shape = RoundedCornerShape(24.dp, 24.dp), modifier = modifier .fillMaxWidth() .height(76.dp), - shape = RoundedCornerShape(24.dp, 24.dp), - color = MaterialTheme.colorScheme.inverseSurface, - contentColor = MaterialTheme.colorScheme.inverseOnSurface, - tonalElevation = 3.dp, ) { Box( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index f9032b9a..1adb2dbb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -72,8 +72,10 @@ import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.base.SurfaceRole import dev.krtirtho.spotube.core.ui.base.VariableIconButton import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant +import dev.krtirtho.spotube.core.ui.base.baseSurfaceTexture import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon @@ -210,7 +212,9 @@ fun AppLargePlayer( } } - val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) + // A translucent tint (not near-opaque) so the blurred art behind still reads through, and + // the material's watery caustics sit on top of it. + val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) Surface( modifier = modifier @@ -234,6 +238,7 @@ fun AppLargePlayer( ) } } + .baseSurfaceTexture(SurfaceRole.PrimaryBackground) ) { Row( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index dd61c618..e5dfbbf8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -55,6 +55,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.SurfaceRole +import dev.krtirtho.spotube.core.ui.base.baseSurfaceMaterial import dev.krtirtho.spotube.core.ui.component.SidebarItem import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar @@ -88,7 +90,8 @@ fun AppSidebar( Column( modifier = modifier .fillMaxHeight() - .width(width), + .width(width) + .baseSurfaceMaterial(role = SurfaceRole.PrimaryBackground), horizontalAlignment = Alignment.CenterHorizontally ) { Column(modifier = Modifier.weight(1f)) { From cfd472016ebb0478adc062d06138a2750678d8a2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Oct 2026 17:17:34 +0600 Subject: [PATCH 037/132] refactor: integrate millennium UI --- composeApp/build.gradle.kts | 13 +- .../core/newpipe/NewPipeService.androidJvm.kt | 11 +- .../spotube/core/di/Modules.android.kt | 3 +- .../LocalNetworkPermission.android.kt | 6 +- .../spotube/media/MediaBrowseHelper.kt | 12 +- .../AndroidLocalMediaDiscoveryService.kt | 36 +- .../media/LocalMediaPermission.android.kt | 9 +- .../composeResources/values/strings.xml | 10 + .../kotlin/dev/krtirtho/spotube/App.kt | 115 +-- .../spotube/core/audioplayer/AudioPlayer.kt | 1 - .../core/audioplayer/AudioPlayerModels.kt | 18 +- .../core/audioplayer/AudioPlayerQueue.kt | 3 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 27 +- .../dev/krtirtho/spotube/core/db/Database.kt | 3 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 21 +- .../spotube/core/discord/DiscordRpcService.kt | 21 +- .../core/discovery/DeviceDiscoveryService.kt | 21 +- .../krtirtho/spotube/core/jam/JamProtocol.kt | 36 +- .../spotube/core/jam/JamRoomClient.kt | 33 +- .../spotube/core/jam/JamRoomService.kt | 36 +- .../core/navigation/NavigationModule.kt | 12 +- .../core/navigation/NavigationState.kt | 6 +- .../spotube/core/newpipe/NewPipeModels.kt | 3 +- .../core/playback/CollectionPlaybackHelper.kt | 36 +- .../core/remote/ConnectionRequestDialog.kt | 62 +- .../core/remote/RemoteControlClient.kt | 3 +- .../core/remote/RemoteControlHandler.kt | 33 +- .../core/remote/RemoteControlProtocol.kt | 9 +- .../core/remote/RemoteControlService.kt | 9 +- .../core/remote/RemotePlaybackController.kt | 24 +- .../spotube/core/scrobble/ScrobbleManager.kt | 21 +- .../server/AlternativeTracksRepository.kt | 3 +- .../spotube/core/server/CacheManager.kt | 12 +- .../spotube/core/server/LocalServer.kt | 6 +- .../core/server/MatchTracksRepository.kt | 3 +- .../spotube/core/server/StreamProxy.kt | 6 +- .../core/server/StreamingUrlRepository.kt | 33 +- .../core/ui/base/AutocompleteTextField.kt | 569 ----------- .../spotube/core/ui/base/BaseUIColorScheme.kt | 381 ------- .../spotube/core/ui/base/BaseUIPreviews.kt | 94 -- .../krtirtho/spotube/core/ui/base/Buttons.kt | 908 ----------------- .../dev/krtirtho/spotube/core/ui/base/Card.kt | 174 ---- .../krtirtho/spotube/core/ui/base/CheckBox.kt | 281 ----- .../krtirtho/spotube/core/ui/base/ChipTabs.kt | 240 ----- .../krtirtho/spotube/core/ui/base/Dialog.kt | 237 ----- .../spotube/core/ui/base/DropdownMenu.kt | 277 ----- .../spotube/core/ui/base/ListRowTile.kt | 240 ----- .../krtirtho/spotube/core/ui/base/Radio.kt | 183 ---- .../krtirtho/spotube/core/ui/base/Scaffold.kt | 121 --- .../krtirtho/spotube/core/ui/base/Slider.kt | 369 ------- .../krtirtho/spotube/core/ui/base/Surface.kt | 699 ------------- .../spotube/core/ui/base/TextField.kt | 291 ------ .../krtirtho/spotube/core/ui/base/Theme.kt | 962 ------------------ .../krtirtho/spotube/core/ui/base/Toggle.kt | 298 ------ .../krtirtho/spotube/core/ui/base/Utils.kt | 88 -- .../ui/component/AdaptiveDialogBottomSheet.kt | 83 +- .../component/AdaptiveDropdownBottomSheet.kt | 333 +++--- .../spotube/core/ui/component/AlbumCard.kt | 12 +- .../core/ui/component/ApplicationMainBar.kt | 16 +- .../spotube/core/ui/component/ArtistCard.kt | 12 +- .../core/ui/component/CollectionDetails.kt | 183 ++-- .../core/ui/component/CollectionView.kt | 113 +- .../spotube/core/ui/component/ErrorDisplay.kt | 236 ++--- .../core/ui/component/FeaturedCarousel.kt | 95 +- .../core/ui/component/LikedTracksCard.kt | 50 +- .../spotube/core/ui/component/PlaylistCard.kt | 12 +- .../ui/component/SharedTransitionLocals.kt | 6 +- .../spotube/core/ui/component/Sidebar.kt | 107 +- .../spotube/core/ui/component/TrackCard.kt | 15 +- .../spotube/core/ui/component/TrackList.kt | 597 +++++------ .../spotube/core/ui/component/TrackOptions.kt | 160 ++- .../spotube/core/ui/component/UserCard.kt | 12 +- .../core/ui/component/VerticalScrollbar.kt | 3 +- .../core/ui/component/cards/AvatarCard.kt | 33 +- .../core/ui/component/cards/PlayableCard.kt | 73 +- .../spotube/core/ui/coverflow/Coverflow.kt | 18 +- .../core/ui/coverflow/CoverflowGeometry.kt | 3 +- .../core/ui/coverflow/CoverflowItem.kt | 22 +- .../core/ui/coverflow/CoverflowParams.kt | 3 +- .../core/ui/coverflow/CoverflowScope.kt | 27 +- .../core/ui/coverflow/CoverflowState.kt | 15 +- .../spotube/core/ui/coverflow/Mirror.kt | 39 +- .../krtirtho/spotube/core/ui/misc/Shimmer.kt | 19 +- .../spotube/core/ui/theming/AppTheme.kt | 233 +++-- .../core/webview/PlatformWebviewScreen.kt | 449 ++++---- .../core/zipline/BuiltInPluginService.kt | 4 +- .../core/zipline/ZiplinePluginService.kt | 689 +++++++------ .../core/zipline/host_apis/RealCryptoAPI.kt | 17 +- .../zipline/host_apis/RealHttpClientAPI.kt | 1 - .../core/zipline/host_apis/RealWebViewAPI.kt | 145 ++- .../plugin_apis/lrclib/RealLRCLibLyricsAPI.kt | 6 +- .../plugin_apis/newpipe_yt/ISRCModels.kt | 6 +- .../plugin_apis/newpipe_yt/ISRCProviders.kt | 2 - .../newpipe_yt/RealNewPipeAudioAPI.kt | 22 +- .../spotube/modules/album/AlbumRepository.kt | 3 +- .../spotube/modules/album/AlbumScreen.kt | 6 +- .../spotube/modules/album/AlbumViewModel.kt | 30 +- .../spotube/modules/artist/ArtistScreen.kt | 297 +++--- .../spotube/modules/artist/ArtistViewModel.kt | 36 +- .../modules/blacklist/BlacklistRepository.kt | 3 +- .../modules/blacklist/BlacklistScreen.kt | 174 ++-- .../modules/blacklist/BlacklistViewModel.kt | 3 +- .../spotube/modules/devices/DevicesScreen.kt | 173 ++-- .../modules/devices/DevicesViewModel.kt | 6 +- .../modules/devices/PlayDestinationPicker.kt | 129 ++- .../modules/devices/RemoteControlScreen.kt | 376 +++---- .../modules/devices/RemoteControlViewModel.kt | 12 +- .../modules/downloads/DownloadManager.kt | 33 +- .../modules/downloads/DownloadProgressIcon.kt | 49 +- .../modules/downloads/DownloadsScreen.kt | 154 ++- .../modules/downloads/DownloadsViewModel.kt | 6 +- .../spotube/modules/home/HomeScreen.kt | 183 ++-- .../modules/home/HomeScreenRepository.kt | 1 - .../modules/home/HomeScreenViewModel.kt | 18 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 206 ++-- .../spotube/modules/jam/JamViewModel.kt | 12 +- .../modules/jam/PlayDestinationPicker.kt | 49 +- .../modules/library/LibraryRepository.kt | 12 +- .../spotube/modules/library/LibraryScreen.kt | 102 +- .../library/album/LibraryAlbumsScreen.kt | 42 +- .../library/album/LibraryAlbumsViewModel.kt | 18 +- .../library/artist/LibraryArtistsScreen.kt | 54 +- .../library/artist/LibraryArtistsViewModel.kt | 24 +- .../local_tracks/LibraryLocalTracksScreen.kt | 157 +-- .../LibraryLocalTracksViewModel.kt | 18 +- .../media/LocalMediaFoldersConfig.kt | 3 +- .../media/LocalMediaLibraryCoordinator.kt | 6 +- .../local_tracks/media/LocalMediaModels.kt | 9 +- .../media/LocalMediaPermission.kt | 3 +- .../media/NoopLocalMediaDiscoveryService.kt | 3 +- .../library/playlist/AddToPlaylistPicker.kt | 140 ++- .../playlist/LibraryPlaylistsScreen.kt | 71 +- .../playlist/LibraryPlaylistsViewModel.kt | 24 +- .../library/playlist/PlaylistFormSheet.kt | 221 ++-- .../spotube/modules/lyrics/LyricsScreen.kt | 225 ++-- .../spotube/modules/lyrics/LyricsViewModel.kt | 10 +- .../playlist/AddTracksToPlaylistDialog.kt | 177 ++-- .../modules/playlist/PlaylistRepository.kt | 3 +- .../modules/playlist/PlaylistScreen.kt | 48 +- .../modules/playlist/PlaylistViewModel.kt | 36 +- .../spotube/modules/plugin/BuiltInPlugins.kt | 12 +- .../modules/plugin/GitHubPluginRepo.kt | 15 +- .../spotube/modules/plugin/PluginManager.kt | 34 +- .../spotube/modules/plugin/PluginModels.kt | 3 +- .../spotube/modules/plugin/PluginScreen.kt | 606 ++++++----- .../spotube/modules/plugin/PluginViewModel.kt | 85 +- .../components/DefaultAbilityPluginGrid.kt | 139 +-- .../modules/plugin/components/PluginCard.kt | 120 ++- .../plugin/components/PluginInstallDialog.kt | 318 +++--- .../modules/plugin/components/PluginLogo.kt | 9 +- .../components/PluginPermissionsDialog.kt | 297 +++--- .../modules/saved_tracks/SavedTracksScreen.kt | 6 +- .../saved_tracks/SavedTracksViewModel.kt | 37 +- .../spotube/modules/search/SearchScreen.kt | 523 ++++------ .../modules/search/SearchScreenViewModel.kt | 82 +- .../modules/settings/JamSettingsScreen.kt | 87 +- .../modules/settings/JamSettingsViewModel.kt | 6 +- .../modules/settings/SettingsModels.kt | 17 +- .../modules/settings/SettingsRepository.kt | 18 +- .../modules/settings/SettingsScreen.kt | 62 +- .../modules/settings/SettingsSidebarLayout.kt | 121 +-- .../modules/settings/SettingsViewModel.kt | 1 - .../modules/settings/SurfaceAccents.kt | 70 -- .../components/InteractiveSettingCards.kt | 223 ++-- .../settings/components/SettingsCardItem.kt | 19 +- .../settings/sections/AppearanceSection.kt | 407 ++------ .../modules/settings/sections/CacheSection.kt | 30 +- .../settings/sections/DesktopSection.kt | 6 +- .../settings/sections/DownloadsSection.kt | 138 ++- .../modules/settings/sections/JamSection.kt | 26 +- .../sections/LanguageRegionSection.kt | 12 +- .../settings/sections/PlaybackSection.kt | 26 +- .../settings/sections/PluginsSection.kt | 12 +- .../settings/sections/UpdatesSection.kt | 3 +- .../modules/settings/sections/utils.kt | 97 +- .../spotube/modules/shell/AppBottombar.kt | 106 +- .../modules/shell/AppExpandedPlayer.kt | 357 ++++--- .../modules/shell/AppFloatingPlayer.kt | 75 +- .../spotube/modules/shell/AppLargePlayer.kt | 252 +++-- .../spotube/modules/shell/AppPlayerState.kt | 9 +- .../spotube/modules/shell/AppShell.kt | 301 +++--- .../modules/shell/AppShellViewModel.kt | 9 +- .../spotube/modules/shell/AppSidebar.kt | 195 ++-- .../modules/shell/PlayerHeartButton.kt | 24 +- .../modules/shell/PlayerOptionDialogs.kt | 276 +++-- .../modules/shell/PlayerOptionsViewModel.kt | 25 +- .../AlternativeTrackContent.kt | 150 ++- .../AlternativeTrackContentViewModel.kt | 15 +- .../AlternativeTrackSheet.kt | 74 +- .../shell/player_queue/PlayerQueueContent.kt | 268 ++--- .../PlayerQueueContentViewModel.kt | 27 +- .../modules/shell/player_queue/QueueSheet.kt | 100 +- .../spotube/modules/update/AppUpdateDialog.kt | 48 +- .../modules/update/AppUpdateService.kt | 12 +- .../modules/update/PluginUpdateDialog.kt | 59 +- .../modules/update/PluginUpdateService.kt | 9 +- .../spotube/modules/welcome/WelcomeContent.kt | 13 +- .../spotube/modules/welcome/WelcomeModels.kt | 6 +- .../modules/welcome/WelcomeRepository.kt | 6 +- .../spotube/modules/welcome/WelcomeScreen.kt | 133 ++- .../modules/welcome/WelcomeViewModel.kt | 10 +- .../spotube/modules/welcome/WelcomeVisuals.kt | 53 +- .../modules/welcome/pages/BrandingPage.kt | 19 +- .../modules/welcome/pages/DonationPage.kt | 56 +- .../modules/welcome/pages/HowItWorksPage.kt | 53 +- .../audioplayer/DeviceAudioPlayerQueueTest.kt | 9 +- .../core/audioplayer/FakePluginProvider.kt | 3 +- .../core/scrobble/ScrobbleManagerTest.kt | 42 +- .../StreamingUrlRepositoryBehaviorTest.kt | 22 +- .../server/StreamingUrlRepositoryFakes.kt | 9 +- .../core/server/StreamingUrlRepositoryTest.kt | 84 +- .../krtirtho/spotube/MainViewController.kt | 1 - .../core/audioplayer/AudioPlayer.ios.kt | 1 - .../core/newpipe/NewPipeService.ios.kt | 3 +- .../krtirtho/spotube/core/paths/Paths.ios.kt | 9 +- .../media/IosLocalMediaDiscoveryService.kt | 3 +- .../media/LocalMediaPermission.ios.kt | 3 +- .../core/audioplayer/AudioPlayer.jvm.kt | 6 +- .../mediacontrol/SystemMediaControlService.kt | 12 +- .../spotube/core/systemtray/SystemTray.kt | 9 +- .../core/systemtray/SystemTrayService.kt | 15 +- .../ui/component/ApplicationMainBar.jvm.kt | 77 +- .../ui/component/VerticalScrollbar.jvm.kt | 11 +- .../kotlin/dev/krtirtho/spotube/main.kt | 7 +- .../media/JvmLocalMediaDiscoveryService.kt | 18 +- .../media/LocalMediaPermission.jvm.kt | 3 +- .../core/audioplayer/AudioPlayerTest.kt | 6 +- .../ui/component/ApplicationMainBar.mobile.kt | 19 +- gradle/libs.versions.toml | 14 +- 229 files changed, 6317 insertions(+), 14840 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3d4027e3..2d8729d9 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -119,7 +119,6 @@ kotlin { // Jetpack Compose dependencies implementation(libs.compose.runtime) implementation(libs.compose.foundation) - implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) @@ -132,6 +131,9 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.dns.sd.kt) + // Millennium UI + implementation(libs.millennium) + // Navigation implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) @@ -164,12 +166,6 @@ kotlin { // icons implementation(libs.feather.icons) - // Material3 adaptive - implementation(libs.jetbrains.material3.adaptive) - implementation(libs.jetbrains.material3.adaptive.layout) - implementation(libs.jetbrains.material3.adaptive.suite) - implementation(libs.jetbrains.material3.adaptiveNavigation3) - implementation(libs.jetbrains.material3.window.size) // androidx-datastore implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) @@ -184,8 +180,6 @@ kotlin { implementation(libs.semver) - implementation(libs.material.kolor) - // crypto implementation(libs.cryptography.core) implementation(libs.cryptography.provider.optimal) @@ -206,7 +200,6 @@ kotlin { // Shimmer effect implementation(libs.compose.shimmer) - implementation(libs.compose.placeholder.material3) // DLNA implementation(libs.dns.sd.kt) diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt index cf8dd550..5231f3b4 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -26,7 +26,6 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.stream.StreamInfoItem - actual class NewPipeService { actual suspend fun searchVideos(query: String): List { return withContext(Dispatchers.IO) { @@ -47,8 +46,7 @@ actual class NewPipeService { url = item.url, uploader = item.uploaderName, durationMs = item.duration, - thumbnailUrl = item.thumbnails.first().url, - ) + thumbnailUrl = item.thumbnails.first().url) } else { null } @@ -67,8 +65,7 @@ actual class NewPipeService { url = stream.content, codec = stream.codec, container = stream.format!!.suffix, - bitrate = stream.bitrate, - ) + bitrate = stream.bitrate) } + buildList { if (extractor.dashMpdUrl.isNotBlank()) add( AudioStream.Lossy( @@ -90,7 +87,6 @@ actual class NewPipeService { ) } - val videoStreams = extractor.videoStreams .filter { stream -> stream.isUrl && stream.format != null } .map { stream -> @@ -98,8 +94,7 @@ actual class NewPipeService { url = stream.content, codec = stream.codec, bitrate = stream.bitrate, - container = stream.format!!.suffix, - ) + container = stream.format!!.suffix) } VideoInfo( id = id, diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt index be29dba7..d3f042d2 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt @@ -45,7 +45,6 @@ actual val platformModules = module { collectionPlaybackHelper = get(), audioPlayerQueue = get(), settingsRepository = get(), - blacklistRepository = get(), - ) + blacklistRepository = get()) } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt index 961a6b7c..409b08d9 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { /* result is picked up by discovery/advertising retry loops */ } + ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ } return { val granted = ContextCompat.checkSelfPermission( context, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) == PackageManager.PERMISSION_GRANTED + Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED if (!granted) { launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt index cd37edbc..9a0a19d8 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt @@ -53,8 +53,7 @@ class MediaBrowseHelper( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val settingsRepository: SettingsRepository, - private val blacklistRepository: BlacklistRepository, -) { + private val blacklistRepository: BlacklistRepository) { companion object { const val MEDIA_ID_ROOT = "root" @@ -253,8 +252,7 @@ class MediaBrowseHelper( .setIsPlayable(false) .build() ) - .build(), - ) + .build()) } private suspend fun buildBrowseItems(): List { @@ -390,8 +388,7 @@ class MediaBrowseHelper( .setIsBrowsable(true) .build() ) - .build(), - ) + .build()) } private suspend fun buildSavedPlaylistsItems(): List { @@ -475,8 +472,7 @@ class MediaBrowseHelper( .setIsPlayable(false) .build() ) - .build(), - ) + .build()) } private suspend fun buildArtistTracksItems(artistId: String): List { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt index 228b7daf..f258bb9b 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt @@ -32,8 +32,7 @@ import java.io.File import java.util.Locale class AndroidLocalMediaDiscoveryService( - private val context: Context, -) : LocalMediaDiscoveryService, KoinComponent { + private val context: Context) : LocalMediaDiscoveryService, KoinComponent { val logger by injectLogger() override suspend fun discoverFolders(roots: List): List { @@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService( MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.MIME_TYPE, - ) + if (Build.VERSION.SDK_INT >= 29) { + MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) { arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) } else { emptyArray() @@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService( artists = listOfNotNull(artist), durationMs = durationMs, album = album, - coverBytes = null, - ) + coverBytes = null) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) acceptedRows += 1 @@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService( LocalMediaFolder( path = folderPath, name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } @@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService( override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { logger.i { "observeChanges: start roots=${roots.size}" } if (!hasReadPermission()) { logger.w { "observeChanges: missing media permission, observer not registered" } @@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService( context.contentResolver.registerContentObserver( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, - observer, - ) + observer) logger.i { "observeChanges: observer registered" } @@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService( artists = emptyList(), durationMs = 0L, album = null, - coverBytes = null, - ) + coverBytes = null) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) } @@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService( LocalMediaFolder( path = folderPath, name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } @@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - ) + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) return (configured + defaults) .map { it.absoluteFile } @@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService( private fun mergeFolders( mediaStoreFolders: List, - fallbackFolders: List, - ): List { + fallbackFolders: List): List { if (fallbackFolders.isEmpty()) return mediaStoreFolders if (mediaStoreFolders.isEmpty()) return fallbackFolders @@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService( LocalMediaFolder( path = path, name = normalizeFolderName(path), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } } @@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService( "wav", "ogg", "opus", - "wma", - ) + "wma") } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt index 65c44906..e3fbc988 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt @@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { var isGranted by remember { mutableStateOf( - context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED, - ) + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) } val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> + contract = ActivityResultContracts.RequestPermission()) { granted -> isGranted = granted || context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED } return LocalMediaPermissionState( isGranted = isGranted, - requestPermission = { launcher.launch(permission) }, - ) + requestPermission = { launcher.launch(permission) }) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b82b5562..a38b2ac1 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -76,6 +76,16 @@ Accent Secondary + Palette + Millennium accent palette. Current: %1$s + Palette + Choose a Millennium accent palette and preview it in light and dark. + Palette + Millennium palette and accent. Current: %1$s + Blue + Orange + Lime + Download folder override Current folder: %1$s Current folder: default music folder diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 648be9af..f7005f50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,13 +22,11 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay @@ -37,9 +35,6 @@ import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.modules.library.LibraryTab @@ -81,8 +76,7 @@ interface NavigationItem { private val tabs = listOf( NavigationItem.Tab("Home", Iconsax.IconsaxHome, Routes.Home), - NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search), -) + NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search)) val sidebarTabs = tabs + listOf( NavigationItem.Group( @@ -95,8 +89,7 @@ val sidebarTabs = tabs + listOf( LibraryTab.Playlists ), NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), - NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists), - ) + NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists)) ), NavigationItem.Group( "On Device", @@ -112,17 +105,13 @@ val sidebarTabs = tabs + listOf( Iconsax.IconsaxDirectboxReceive, Routes.Library, LibraryTab.Downloads - ), - ) + )) ), NavigationItem.Group( "Connect", items = listOf( NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), - NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam), - ), - ), -) + NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam)))) val bottombarTabs = tabs + listOf( NavigationItem.Tab("Library", Iconsax.IconsaxMusicLibrary, Routes.Library), NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings) @@ -159,69 +148,51 @@ fun App( } SpotubeTheme(settings = userSettings) { - val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f - val accent = if (isDark) { - userSettings.accentColor.toDarkColor() - } else { - userSettings.accentColor.toLightColor() - } - val baseUITheme = rememberBaseUITheme( - surfaceTheme = userSettings.surfaceTheme, - accentColor = accent, - ) - CompositionLocalProvider( - LocalBaseUITheme provides baseUITheme, - LocalBaseUIColors provides baseUITheme.colors, - ) { - Box(modifier = Modifier.fillMaxSize()) { - val currentRoute = - navigationState.backStacks[navigationState.topLevelRoute]?.last() + Box(modifier = Modifier.fillMaxSize()) { + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() - Box(Modifier.fillMaxSize()) { - AppShell(navigator, navigationState) { - SharedTransitionLayout { - CompositionLocalProvider( - LocalSharedTransitionScope provides this@SharedTransitionLayout, - ) { - Column { - NavDisplay( - modifier = Modifier.fillMaxSize(), - onBack = navigator::pop, - entries = navigationState.toEntries(koinEntryProvider()) - ) - } + Box(Modifier.fillMaxSize()) { + AppShell(navigator, navigationState) { + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout) { + Column { + NavDisplay( + modifier = Modifier.fillMaxSize(), + onBack = navigator::pop, + entries = navigationState.toEntries(koinEntryProvider()) + ) } } } - when (currentRoute) { - Routes.WebView -> WebViewScreen(koinInject()) - else -> {} - } + } + when (currentRoute) { + Routes.WebView -> WebViewScreen(koinInject()) + else -> {} } } - content() - availableAppUpdate?.let { update -> - AppUpdateDialog( - update = update, - onDismiss = appUpdateService::dismissUpdate, - onIgnore = appUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } - appUpdateService.dismissUpdate() - }, - ) - } - availablePluginUpdate?.let { update -> - PluginUpdateDialog( - update = update, - onDismiss = pluginUpdateService::dismissUpdate, - onIgnore = pluginUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser(update.info.directDownloadUrl) } - pluginUpdateService.dismissUpdate() - }, - ) - } + } + content() + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + appUpdateService.dismissUpdate() + }) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser(update.info.directDownloadUrl) } + pluginUpdateService.dismissUpdate() + }) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt index 67cbe65f..ecdf309b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlin.time.Duration - interface AudioPlayerInterface { // // Playback suspend fun play() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt index da119020..e52cbbfb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt @@ -30,8 +30,7 @@ data class MediaItem( val duration: Duration, val coverURL: String, val url: String, - val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, -) + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) enum class LoopState { NONE, ONE, ALL; @@ -60,8 +59,7 @@ sealed interface QueueEntry { val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, - override val addedBy: String = "", - ) : QueueEntry + override val addedBy: String = "") : QueueEntry @Serializable @SerialName("local") @@ -72,8 +70,7 @@ sealed interface QueueEntry { val album: String?, val coverBytes: ByteArray?, override val url: String, - override val addedBy: String = "", - ) : QueueEntry { + override val addedBy: String = "") : QueueEntry { override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false @@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry { @Serializable @SerialName("playlist") data class Playlist( - override val id: String, - ) : QueueCollectionEntry + override val id: String) : QueueCollectionEntry @Serializable @SerialName("album") data class Album( - override val id: String, - ) : QueueCollectionEntry + override val id: String) : QueueCollectionEntry @Serializable @SerialName("saved_tracks") @@ -132,5 +127,4 @@ data class PersistedQueueState( val entries: List, val currentIndex: Int, val currentCollectionEntry: QueueCollectionEntry? = null, - val collectionHistory: List = emptyList(), -) + val collectionHistory: List = emptyList()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt index 8a524add..a373952a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt @@ -29,8 +29,7 @@ interface AudioPlayerQueue { entries: List, autoPlay: Boolean = true, startPosition: Int = 0, - collectionEntry: QueueCollectionEntry? = null, - ) + collectionEntry: QueueCollectionEntry? = null) suspend fun addToQueue(entry: QueueEntry) suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry? = null) suspend fun addAllAfterCurrent(entries: List) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 230b0c78..95de3699 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue( private val settingsProvider: SettingsProvider, private val repository: QueueStateRepository, private val pluginProvider: PluginProvider, - private val blacklistRepository: BlacklistRepository, -) : AudioPlayerQueue, KoinComponent { + private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() @@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue( override val queueFlow: StateFlow> = combine( audioPlayer.playlistFlow, - entryByMediaUrl, - ) { playlist, knownEntries -> + entryByMediaUrl) { playlist, knownEntries -> playlist.map { mediaItem -> knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() } @@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue( override val currentQueueEntryFlow: StateFlow = combine( audioPlayer.currentMediaItemFlow, - entryByMediaUrl, - ) { mediaItem, knownEntries -> + entryByMediaUrl) { mediaItem, knownEntries -> mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) @@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue( combine( audioPlayer.currentMediaItemFlow, audioPlayer.playlistFlow, - audioPlayer.loopStateFlow, - ) { currentMedia, playlist, loopState -> + audioPlayer.loopStateFlow) { currentMedia, playlist, loopState -> Triple(currentMedia, playlist, loopState) }.collect { (currentMedia, playlist, loopState) -> if (currentMedia == null || playlist.isEmpty()) return@collect @@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue( entries: List, autoPlay: Boolean, startPosition: Int, - collectionEntry: QueueCollectionEntry?, - ) { + collectionEntry: QueueCollectionEntry?) { logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } val mediaItems = entries.mapNotNull { it.toMediaItem() } if (mediaItems.isEmpty()) { @@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue( entries = queueEntries, currentIndex = normalizedCurrentIndex, currentCollectionEntry = currentCollectionEntryState.value, - collectionHistory = collectionHistoryState.value, - ) + collectionHistory = collectionHistoryState.value) ) } @@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue( duration = track.durationMs.milliseconds, coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), url = resolvedUrl, - protocol = protocol, - ) + protocol = protocol) } is QueueEntry.LocalTrack -> { @@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue( album = album.orEmpty(), duration = duration.milliseconds, coverURL = "", - url = url, - ) + url = url) } } } @@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue( duration = duration.inWholeMilliseconds, album = album.ifBlank { null }, coverBytes = null, - url = url, - ) + url = url) } private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt index 573efaa1..7b08d112 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt @@ -86,8 +86,7 @@ class Database(val paths: Paths) { "spotube_audio_player_queue.preferences_pb", "spotube_local_media.preferences_pb", "spotube_blacklist.preferences_pb", - "spotube_cookies.preferences_pb", - ) + "spotube_cookies.preferences_pb") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index bdf28574..a8251a64 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -168,8 +168,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Saved Tracks @@ -183,8 +182,7 @@ val sharedModules = module { libraryRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Artist @@ -199,8 +197,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Blacklist @@ -212,8 +209,7 @@ val sharedModules = module { JamViewModel( jamRoomService = get(), shareService = get(), - settingsProvider = get(), - ) + settingsProvider = get()) } // Album @@ -229,8 +225,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Lyrics @@ -244,8 +239,7 @@ val sharedModules = module { savedTracksRepository = get(), artistRepository = get(), audioPlayerQueue = get(), - blacklistRepository = get(), - ) + blacklistRepository = get()) } singleOf(::MatchedTracksRepository) { bind() } single { @@ -253,8 +247,7 @@ val sharedModules = module { pluginManager = get(), audioPlayerQueue = get(), matchedTracksRepository = get(), - settingsRepository = get(), - ) + settingsRepository = get()) } singleOf(::CacheManager) singleOf(::AlternativeTracksRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt index b7454957..9240a6bf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt @@ -34,8 +34,7 @@ import kotlin.time.Clock class DiscordRpcService( private val audioPlayer: AudioPlayerInterface, - private val settingsProvider: SettingsProvider, -) : KoinComponent, AutoCloseable { + private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable { private val logger by injectLogger() @@ -61,8 +60,7 @@ class DiscordRpcService( audioPlayer.playerStateFlow, audioPlayer.currentMediaItemFlow, audioPlayer.positionFlow, - audioPlayer.durationFlow, - ) { settings, playerState, mediaItem, position, duration -> + audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration -> DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration) }.collect { state -> handleStateChange(state) @@ -86,8 +84,7 @@ class DiscordRpcService( album = mediaItem.album, coverUrl = mediaItem.coverURL, positionMs = state.position.inWholeMilliseconds, - durationMs = state.duration.inWholeMilliseconds, - ) + durationMs = state.duration.inWholeMilliseconds) } PlayerState.PAUSED -> { val mediaItem = state.mediaItem ?: return @@ -98,8 +95,7 @@ class DiscordRpcService( album = mediaItem.album, coverUrl = mediaItem.coverURL, positionMs = state.position.inWholeMilliseconds, - durationMs = state.duration.inWholeMilliseconds, - ) + durationMs = state.duration.inWholeMilliseconds) } PlayerState.IDLE, PlayerState.COMPLETED -> { clearPresence() @@ -158,8 +154,7 @@ class DiscordRpcService( album: String, coverUrl: String, positionMs: Long, - durationMs: Long, - ) { + durationMs: Long) { val client = rpcClient ?: return try { client.updatePresence( @@ -168,8 +163,7 @@ class DiscordRpcService( album = album, coverUrl = coverUrl, positionMs = positionMs, - durationMs = durationMs, - ) + durationMs = durationMs) } catch (e: Exception) { rpcClient = null connectionFailed = true @@ -210,6 +204,5 @@ class DiscordRpcService( val playerState: PlayerState, val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, val position: kotlin.time.Duration, - val duration: kotlin.time.Duration, - ) + val duration: kotlin.time.Duration) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt index 25537c3f..b525682e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -30,8 +30,7 @@ data class DiscoveredDevice( val type: String, val host: String, val port: Int, - val deviceId: String, -) { + val deviceId: String) { val key: String get() = "$name$type".replace(".", "") } @@ -55,8 +54,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), - ) + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) DiscoveryState.Discovered(device = device, resolve = event.resolve) } @@ -66,8 +64,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), - ) + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) DiscoveryState.Resolved(device = device) } @@ -77,8 +74,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = "", - ) + deviceId = "") DiscoveryState.Removed(device = device) } } @@ -88,8 +84,7 @@ class DeviceDiscoveryService { name: String, port: Int, deviceId: String, - registerTimeoutMs: Long = 5_000, - ): NetService { + registerTimeoutMs: Long = 5_000): NetService { val service = createService(name, port, deviceId) service.register(timeoutInMs = registerTimeoutMs) return service @@ -98,11 +93,9 @@ class DeviceDiscoveryService { fun createService( name: String, port: Int, - deviceId: String, - ): NetService = createNetService( + deviceId: String): NetService = createNetService( type = SERVICE_TYPE, name = name, port = port, - txt = mapOf(TXT_DEVICE_ID to deviceId), - ) + txt = mapOf(TXT_DEVICE_ID to deviceId)) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt index a51b8cb5..f007f635 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -46,8 +46,7 @@ sealed class JamMessage { */ val follow: Boolean = false, /** Host's live play state — late-joining guests start with it. */ - val isPlaying: Boolean = false, - ) : JamMessage() + val isPlaying: Boolean = false) : JamMessage() @Serializable @SerialName("playbackCommand") @@ -57,22 +56,19 @@ sealed class JamMessage { @SerialName("suggestTrack") data class SuggestTrack( val mediaItem: JamMediaItem, - val addedBy: String = "", - ) : JamMessage() + val addedBy: String = "") : JamMessage() @Serializable @SerialName("suggestPlaylist") data class SuggestPlaylist( val tracks: List, - val addedBy: String = "", - ) : JamMessage() + val addedBy: String = "") : JamMessage() @Serializable @SerialName("kick") data class Kick( val participantId: String, - val reason: String = "kicked", - ) : JamMessage() + val reason: String = "kicked") : JamMessage() @Serializable @SerialName("leave") @@ -104,8 +100,7 @@ data class JamPresence( val clientId: String, val displayName: String, val isHost: Boolean, - val left: Boolean = false, -) + val left: Boolean = false) @Serializable data class JamMediaItem( @@ -117,8 +112,7 @@ data class JamMediaItem( val durationMs: Long, val coverUrl: String, val protocol: String, - val addedBy: String = "", -) { + val addedBy: String = "") { companion object { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { is QueueEntry.StreamingTrack -> JamMediaItem( @@ -132,8 +126,7 @@ data class JamMediaItem( ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url .orEmpty(), protocol = entry.protocol.name, - addedBy = entry.addedBy, - ) + addedBy = entry.addedBy) is QueueEntry.LocalTrack -> JamMediaItem( url = entry.url, @@ -144,8 +137,7 @@ data class JamMediaItem( durationMs = entry.duration, coverUrl = "", protocol = "PROGRESSIVE", - addedBy = entry.addedBy, - ) + addedBy = entry.addedBy) } fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( @@ -158,8 +150,7 @@ data class JamMediaItem( coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url .orEmpty(), - protocol = "PROGRESSIVE", - ) + protocol = "PROGRESSIVE") fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( url = item.url, @@ -168,8 +159,7 @@ data class JamMediaItem( album = item.album, durationMs = item.duration.inWholeMilliseconds, coverUrl = item.coverURL, - protocol = item.protocol.name, - ) + protocol = item.protocol.name) fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( title = item.title, @@ -179,8 +169,7 @@ data class JamMediaItem( coverURL = item.coverUrl, url = item.url, protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol - .valueOf(item.protocol.ifBlank { "PROGRESSIVE" }), - ) + .valueOf(item.protocol.ifBlank { "PROGRESSIVE" })) } } @@ -188,8 +177,7 @@ data class JamMediaItem( data class JamParticipant( val id: String, val displayName: String, - val isHost: Boolean, -) + val isHost: Boolean) @Serializable enum class JamRole { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt index 83531f19..c09b4e20 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt @@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName import com.ditchoom.mqtt.controlpacket.WillConfig import com.ditchoom.mqtt5.controlpacket.ConnectionRequest import dev.krtirtho.spotube.modules.settings.JamBroker -import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -47,6 +45,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.seconds /** * Thin facade over the Ditchoom MQTT 5 client for one jam room. @@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent { code: String, clientId: String, displayName: String, - isHost: Boolean, - ): Result { + isHost: Boolean): Result { disconnect() if (broker.host.isBlank()) { return Result.failure(IllegalArgumentException("No jam broker configured")) @@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent { clientId = clientId, displayName = displayName, isHost = isHost, - left = false, - ) + left = false) val mqtt = startClient(broker, clientId) client = mqtt @@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent { publishJson( presenceTopic(code, presence.clientId), json.encodeToString(JamPresence.serializer(), presence), - retain = true, - ) + retain = true) } /** Marks us as the host in presence (host takeover). */ @@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent { publishJson( presenceTopic(code, presence.clientId), json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), - retain = true, - ) + retain = true) } } @@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent { host = broker.host, port = broker.port, tlsEnabled = broker.useTls, - connectionTimeout = broker.connectionTimeoutSeconds.seconds, - ) + connectionTimeout = broker.connectionTimeoutSeconds.seconds) val code = roomCode ?: "unset" val will = WillConfig.Enabled( topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), payload = json.encodeToString( JamPresence.serializer(), - JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true), - ).toReadBuffer(Charset.UTF8), + JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8), qos = QualityOfService.AT_LEAST_ONCE, - retain = true, - ) + retain = true) val request = ConnectionRequest( clientId = clientId, keepAliveSeconds = broker.keepAliveSeconds, cleanStart = true, userName = broker.username, password = broker.password, - will = will, - ) + will = will) val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) val brokerRef = persistence.addBroker(connection, request) return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) @@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent { val operation = mqtt.subscribe( roomFilter(code), OpaquePublishPayloadCodec, - QualityOfService.AT_LEAST_ONCE, - ) { publish, payload -> + QualityOfService.AT_LEAST_ONCE) { publish, payload -> route(publish.topic.toString(), payload) } runCatching { operation.subAck.await() } @@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent { topicName = topic, qos = QualityOfService.AT_LEAST_ONCE, payload = payload.toReadBuffer(Charset.UTF8), - retain = retain, - ) + retain = retain) }.onFailure { e -> log.w(e) { "Failed to publish to $topic" } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index f8c15a2b..b9918040 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -59,15 +59,13 @@ private data class PlaybackBroadcast( val queue: List, val current: QueueEntry?, val shuffle: Boolean, - val playerState: PlayerState, -) + val playerState: PlayerState) class JamRoomService( private val jamClient: JamRoomClient, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val settingsRepository: SettingsRepository, -) { + private val settingsRepository: SettingsRepository) { private val log = Logger.withTag("JamRoomService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -171,8 +169,7 @@ class JamRoomService( code = code, clientId = localClientId, displayName = name, - isHost = true, - ).map { + isHost = true).map { _role.value = JamRole.Host _roomCode.value = code _participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) @@ -206,8 +203,7 @@ class JamRoomService( code = normalized, clientId = localClientId, displayName = name, - isHost = false, - ).map { + isHost = false).map { _role.value = JamRole.Guest _roomCode.value = normalized _participants.value = emptyList() @@ -261,8 +257,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestTrack( mediaItem = JamMediaItem.fromTrack(track), - addedBy = localClientId, - ) + addedBy = localClientId) ) } @@ -271,8 +266,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestPlaylist( tracks = tracks.map(JamMediaItem::fromTrack), - addedBy = localClientId, - ) + addedBy = localClientId) ) } @@ -296,8 +290,7 @@ class JamRoomService( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, audioPlayer.shuffleModeFlow, - audioPlayer.playerStateFlow, - ) { queue, current, shuffle, playerState -> + audioPlayer.playerStateFlow) { queue, current, shuffle, playerState -> PlaybackBroadcast(queue, current, shuffle, playerState) } .onEach { broadcast -> @@ -314,8 +307,7 @@ class JamRoomService( currentIndex = index.coerceAtLeast(0), shuffleEnabled = broadcast.shuffle, follow = now() > autoAdvanceDeadlineMs, - isPlaying = broadcast.playerState == PlayerState.PLAYING, - ) + isPlaying = broadcast.playerState == PlayerState.PLAYING) ) } .launchIn(this) @@ -374,8 +366,7 @@ class JamRoomService( audioPlayerQueue.load( entries = items.map { it.toQueueEntry() }, autoPlay = wasPlaying, - startPosition = startIndex, - ) + startPosition = startIndex) }.onFailure { log.w(it) { "Failed to apply jam queue" } } return } @@ -524,13 +515,11 @@ class JamRoomService( explicit = null, popularity = null, isrcCode = null, - externalUri = null, - ), + externalUri = null), url = "", protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } .getOrDefault(StreamProtocol.PROGRESSIVE), - addedBy = addedBy, - ) + addedBy = addedBy) else -> QueueEntry.LocalTrack( name = title, @@ -539,8 +528,7 @@ class JamRoomService( album = album.ifBlank { null }, coverBytes = null, url = url, - addedBy = addedBy, - ) + addedBy = addedBy) } private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index f1adc2f0..4fc959dc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -125,8 +125,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { ArtistScreen( @@ -136,8 +135,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { AlbumScreen( @@ -148,8 +146,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { LyricsScreen(viewModel = koinViewModel()) @@ -162,8 +159,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { BlacklistScreen() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt index 8845db6b..e9e65bd9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt @@ -46,8 +46,7 @@ val TOP_LEVEL_ROUTES = setOf( Routes.Library, Routes.Settings, // Bootstrap destination: needs its own back stack when the app starts on it. - Routes.Welcome, -) + Routes.Welcome) val serializersConfig = SavedStateConfiguration { serializersModule = SerializersModule { @@ -123,8 +122,7 @@ fun NavigationState.toEntries( ): SnapshotStateList> { val decoratedEntries = backStacks.mapValues { (_, stack) -> val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - ) + rememberSaveableStateHolderNavEntryDecorator()) rememberDecoratedNavEntries( backStack = stack, entryDecorators = decorators, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt index 36009bd8..817e89fd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt @@ -49,5 +49,4 @@ data class MediaStream( val container: String, val bitrate: Int, val isAudio: Boolean, - val isVideo: Boolean, -) \ No newline at end of file + val isVideo: Boolean) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 6d22e903..01b4ea8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -34,8 +34,7 @@ class CollectionPlaybackHelper( private val savedTracksRepository: SavedTracksRepository, private val artistRepository: ArtistRepository, private val audioPlayerQueue: AudioPlayerQueue, - private val blacklistRepository: BlacklistRepository, -) { + private val blacklistRepository: BlacklistRepository) { suspend fun playAlbum(albumId: String, shuffle: Boolean = false) { if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it } @@ -44,8 +43,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } } @@ -54,8 +52,7 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } } @@ -87,8 +84,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) { @@ -99,8 +95,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } } @@ -109,8 +104,7 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } } @@ -142,8 +136,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } suspend fun playSavedTracks(shuffle: Boolean = false) { @@ -154,8 +147,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } } @@ -166,8 +158,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -190,8 +181,7 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } } @@ -216,8 +206,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } private suspend fun fetchAllAlbumTracks(albumId: String): List { @@ -313,8 +302,7 @@ class CollectionPlaybackHelper( private fun isTrackBlacklisted( track: MetadataTrack, blacklistedTrackIds: Set, - blacklistedArtistIds: Set, - ): Boolean { + blacklistedArtistIds: Set): Boolean { return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt index cdda53c0..995d66e5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt @@ -17,17 +17,24 @@ package dev.krtirtho.spotube.core.remote -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import androidx.compose.ui.unit.dp +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog import org.koin.compose.koinInject @Composable @@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() { } pendingRequest?.let { request -> - AdaptiveDialogBottomSheet( - onDismiss = { + MillenniumDialog( + visible = true, + onDismissRequest = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) pendingRequest = null }, - title = { - Text( - text = "Remote Control Request", - style = MaterialTheme.typography.titleLarge, - ) - }, - content = { - Text( - text = "\"${request.deviceName}\" wants to control playback on this device.", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, - ) - }, - actions = { - Button( + title = "Remote Control Request") { + Text( + text = "\"${request.deviceName}\" wants to control playback on this device.", + style = AppTheme.typography.bodyMedium, + textAlign = TextAlign.Start) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically) { + MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) pendingRequest = null }, - ) { + style = MillenniumButtonStyle.Secondary) { Text("Deny") } - Button( + MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) pendingRequest = null - }, - ) { + }) { Text("Allow") } - Button( + MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) pendingRequest = null }, - ) { + style = MillenniumButtonStyle.Quiet) { Text("Allow Always") } - }, - ) + } + } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index 8f2adeea..5dd7c43b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -162,8 +162,7 @@ class RemoteControlClient { val envelope = CommandEnvelope( commandId = randomShortId(), - command = command, - ) + command = command) try { val text = json.encodeToString(CommandEnvelope.serializer(), envelope) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 9307ea65..f593f5ad 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -48,13 +48,11 @@ class RemoteControlHandler( private val settingsRepository: SettingsRepository, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val collectionPlaybackHelper: CollectionPlaybackHelper, -) : KoinComponent { + private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent { val logger by injectLogger() val incomingConnectionRequests = MutableSharedFlow( - extraBufferCapacity = 16, - ) + extraBufferCapacity = 16) private val json = Json { ignoreUnknownKeys = true @@ -88,8 +86,7 @@ class RemoteControlHandler( deviceId = deviceId ?: "unknown", deviceName = deviceName, sessionId = session.call.request.headers["X-Request-Id"] - ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}", - ) + ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}") incomingConnectionRequests.emit(request) val response = waitForRequestResolution(request.sessionId) if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { @@ -225,8 +222,7 @@ class RemoteControlHandler( entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } is RemoteControlCommand.AddTrackToQueue -> { audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) @@ -300,8 +296,7 @@ class RemoteControlHandler( private suspend fun handleCollectionSource( source: String, action: RemoteCollectionAction, - shuffle: Boolean = false, - ) { + shuffle: Boolean = false) { logger.d { "Remote collection $action for source: $source" } when { source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { @@ -359,8 +354,7 @@ class RemoteControlHandler( currentTrackTitle = current?.titleOrNull(), currentTrackArtists = current?.artistsOrNull(), currentTrackAlbum = current?.albumOrNull(), - currentTrackCoverUrl = current?.coverUrlOrNull(), - ) + currentTrackCoverUrl = current?.coverUrlOrNull()) val text = json.encodeToString(RemoteControlEvent.serializer(), state) session.send(Frame.Text(text)) } @@ -375,8 +369,7 @@ class RemoteControlHandler( } val event = RemoteControlEvent.QueueUpdated( entries = queue.map { it.toRemoteQueueEntry() }, - currentIndex = currentIndex, - ) + currentIndex = currentIndex) val text = json.encodeToString(RemoteControlEvent.serializer(), event) session.send(Frame.Text(text)) } @@ -403,8 +396,7 @@ class RemoteControlHandler( artists = track.artists.joinToString(", ") { artist -> artist.name }, album = track.album?.title, coverUrl = coverUrlOrNull(), - durationMs = track.durationMs, - ) + durationMs = track.durationMs) is QueueEntry.LocalTrack -> RemoteQueueEntry( mediaUrl = url, @@ -413,8 +405,7 @@ class RemoteControlHandler( artists = artists.joinToString(", "), album = album, coverUrl = null, - durationMs = duration, - ) + durationMs = duration) } private fun QueueEntry.mediaKey(): String = when (this) { @@ -447,8 +438,7 @@ class RemoteControlHandler( @Serializable data class CommandEnvelope( val commandId: String, - val command: RemoteControlCommand, -) + val command: RemoteControlCommand) private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" @@ -464,8 +454,7 @@ enum class RemoteCollectionAction { data class ConnectionRequest( val deviceId: String, val deviceName: String, - val sessionId: String, -) + val sessionId: String) enum class ConnectionRequestResponse { Allow, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index e2ab1bd9..dde77ef0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -119,15 +119,13 @@ sealed class RemoteControlEvent { val currentTrackTitle: String?, val currentTrackArtists: String?, val currentTrackAlbum: String?, - val currentTrackCoverUrl: String?, - ) : RemoteControlEvent() + val currentTrackCoverUrl: String?) : RemoteControlEvent() @Serializable @SerialName("queueUpdated") data class QueueUpdated( val entries: List, - val currentIndex: Int, - ) : RemoteControlEvent() + val currentIndex: Int) : RemoteControlEvent() @Serializable @SerialName("ack") @@ -146,5 +144,4 @@ data class RemoteQueueEntry( val artists: String, val album: String?, val coverUrl: String?, - val durationMs: Long, -) + val durationMs: Long) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt index 86b920d5..3d49aa31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -53,8 +53,7 @@ import kotlin.random.Random class RemoteControlService( private val settingsRepository: SettingsRepository, private val discoveryService: DeviceDiscoveryService, - private val localServer: LocalServer, -) { + private val localServer: LocalServer) { private val log = Logger.withTag("RemoteControlService") private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -78,8 +77,7 @@ class RemoteControlService( scope.launch { combine( settingsRepository.userSettings, - localServer.port, - ) { settings, port -> settings to port } + localServer.port) { settings, port -> settings to port } .distinctUntilChanged() .collect { (settings, port) -> if (settings.allowRemoteControl && port != null) { @@ -139,8 +137,7 @@ class RemoteControlService( val service = discoveryService.createService( name = serviceName, port = port, - deviceId = deviceId, - ) + deviceId = deviceId) pendingService = service try { service.register(timeoutInMs = REGISTER_TIMEOUT_MS) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 57c89e82..3627dff7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest { val type: RemoteCollectionType, val id: String, val startTrack: MetadataTrack? = null, - val shuffle: Boolean = false, - ) : PlaybackDestinationRequest + val shuffle: Boolean = false) : PlaybackDestinationRequest data class Track( override val title: String, override val action: PlaybackDestinationAction, - val track: MetadataTrack, - ) : PlaybackDestinationRequest + val track: MetadataTrack) : PlaybackDestinationRequest data class Tracks( override val title: String, override val action: PlaybackDestinationAction, - val tracks: List, - ) : PlaybackDestinationRequest + val tracks: List) : PlaybackDestinationRequest } /** @@ -90,8 +87,7 @@ class RemotePlaybackController( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, - private val jamRoomService: JamRoomService, -) : KoinComponent { + private val jamRoomService: JamRoomService) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -113,8 +109,7 @@ class RemotePlaybackController( id: String, title: String, startTrack: MetadataTrack? = null, - shuffle: Boolean = false, - ) { + shuffle: Boolean = false) { request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle)) } @@ -205,8 +200,7 @@ class RemotePlaybackController( QueueEntry.StreamingTrack( track = track, url = "", - addedBy = jamRoomService.participantClientId, - ) + addedBy = jamRoomService.participantClientId) } ) @@ -291,8 +285,7 @@ class RemotePlaybackController( entries = listOf(entry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } PlaybackDestinationAction.AddToQueue -> { @@ -319,8 +312,7 @@ class RemotePlaybackController( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } PlaybackDestinationAction.AddToQueue -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt index 7e622b32..b48408b2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt @@ -52,8 +52,7 @@ class ScrobbleManager( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, private val scrobblePluginSource: ScrobblePluginSource, - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), -) : KoinComponent { + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent { private val logger by injectLogger() @@ -69,14 +68,12 @@ class ScrobbleManager( audioPlayerQueue.currentQueueEntryFlow, audioPlayer.playerStateFlow, audioPlayer.positionFlow, - audioPlayer.durationFlow, - ) { entry, playerState, position, duration -> + audioPlayer.durationFlow) { entry, playerState, position, duration -> PlaybackSnapshot( entry = entry, playerState = playerState, positionMs = position.inWholeMilliseconds, - playerDurationMs = duration.inWholeMilliseconds, - ) + playerDurationMs = duration.inWholeMilliseconds) }.collect(::onPlaybackSnapshot) } } @@ -137,8 +134,7 @@ class ScrobbleManager( scrobbled = true val track = entry.toScrobbleTrack( timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), - durationMs = durationMs, - ) + durationMs = durationMs) scope.launch { submitScrobble(track) } } @@ -197,8 +193,7 @@ class ScrobbleManager( artistName = track.artists.joinToString(", ") { it.name }, albumName = track.album?.title, streamingProvider = resolveStreamingProvider(track), - durationMs = durationMs, - ) + durationMs = durationMs) is QueueEntry.LocalTrack -> ScrobbleTrack( timestamp = timestamp, @@ -209,8 +204,7 @@ class ScrobbleManager( artistName = artists.joinToString(", "), albumName = album, streamingProvider = LOCAL_PROVIDER, - durationMs = durationMs, - ) + durationMs = durationMs) } } @@ -245,8 +239,7 @@ class ScrobbleManager( val entry: QueueEntry?, val playerState: PlayerState, val positionMs: Long, - val playerDurationMs: Long, - ) + val playerDurationMs: Long) companion object { /** Tracks shorter than this are never scrobbled (Last.fm rule). */ diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt index 77ad48f0..fa70b004 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt @@ -29,8 +29,7 @@ class AlternativeTracksRepository( private val matchedTracksRepository: TrackSourceRepository, private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, - private val cacheManager: CacheManager, -) : KoinComponent { + private val cacheManager: CacheManager) : KoinComponent { private val logger by injectLogger() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt index c5db9b1d..b2c73d19 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt @@ -51,13 +51,11 @@ data class CacheEntry( val filename: String, val sizeBytes: Long, val createdAtMs: Long, - val contentType: String = "application/octet-stream", -) + val contentType: String = "application/octet-stream") class CacheManager( private val paths: Paths, - private val settingsRepository: SettingsRepository, -) { + private val settingsRepository: SettingsRepository) { private val fileSystem: FileSystem = FileSystem.SYSTEM private val cacheMutex: Mutex = Mutex() private val json: Json = Json { ignoreUnknownKeys = true } @@ -160,15 +158,13 @@ class CacheManager( trackId: String, filename: String, allBytes: ByteArray, - contentType: String, - ) { + contentType: String) { val entry = CacheEntry( trackId = trackId, filename = filename, sizeBytes = allBytes.size.toLong(), createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), - contentType = contentType, - ) + contentType = contentType) cacheMutex.withLock { val index = readCacheIndex() writeCacheIndex(CacheIndex( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt index e23f9b02..26a57426 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt @@ -60,8 +60,7 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, - private val remoteControlHandler: RemoteControlHandler, -) : KoinComponent { + private val remoteControlHandler: RemoteControlHandler) : KoinComponent { val logger by injectLogger() private val httpClient = HttpClient { @@ -99,8 +98,7 @@ class LocalServer( audioPlayerQueue = audioPlayerQueue, isCachingEnabled = { cachedCacheEnabled.value }, activePort = { activePort.value }, - scope = scope, - ) + scope = scope) } companion object { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt index d0170c1b..8c006c5d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt @@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json @Serializable data class PluginTaggedAudioSource( val pluginId: String, - val source: AudioSource.Basic, -) + val source: AudioSource.Basic) /** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */ interface TrackSourceRepository { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt index ede7b89c..02e3f6c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt @@ -62,8 +62,7 @@ internal class StreamProxy( private val audioPlayerQueue: AudioPlayerQueue, private val isCachingEnabled: () -> Boolean, private val activePort: () -> Int?, - private val scope: CoroutineScope, -): KoinComponent { + private val scope: CoroutineScope): KoinComponent { val logger by injectLogger() companion object { @@ -414,8 +413,7 @@ internal class StreamProxy( trackId = trackIdForCache, filename = cacheFilename, allBytes = allBytes, - contentType = cacheContentType, - ) + contentType = cacheContentType) scope.launch { cacheManager.evictIfNeeded() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt index 7ba99621..7ff57906 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt @@ -48,28 +48,24 @@ data class CachedStreamUrl( // The id of the audio plugin that produced this entry. Guards against a stale entry // (e.g. a YouTube video URL) being served after the user switches to a different // audio plugin, which would otherwise play back completely unrelated content. - val pluginId: String, -) + val pluginId: String) private data class CachedAlternatives( val pluginId: String, - val sources: List, -) + val sources: List) data class StreamInfo( val url: String, val protocol: StreamProtocol, val codec: String, - val container: String, -) + val container: String) class StreamingUrlRepository( private val pluginManager: AudioPluginSource, 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) : KoinComponent { private val logger by injectLogger() private val streamUrlCacheMutex = Mutex() @@ -133,8 +129,7 @@ class StreamingUrlRepository( val resolvedStream = selectPreferredAudioStream( stream.streams, preferredFormat, - preferredQuality, - ) + preferredQuality) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -193,8 +188,7 @@ class StreamingUrlRepository( val resolvedStream = selectPreferredAudioStream( stream.streams, preferredFormat, - preferredQuality, - ) + preferredQuality) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -207,15 +201,13 @@ class StreamingUrlRepository( url = normalizeManifestUrl(url, protocol), protocol = protocol, codec = codec, - container = container, - ) + container = container) private suspend fun getCachedStreamUrl( trackId: String, preferredFormat: AudioFormat, preferredQuality: AudioQuality, - pluginId: String, - ): StreamInfo? { + pluginId: String): StreamInfo? { val now = Clock.System.now().toEpochMilliseconds() return streamUrlCacheMutex.withLock { val cached = streamUrlCache[trackId] ?: return@withLock null @@ -241,8 +233,7 @@ class StreamingUrlRepository( preferredQuality: AudioQuality, source: AudioSource.Streamed, selectedStream: AudioStream, - pluginId: String, - ) { + pluginId: String) { val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs streamUrlCacheMutex.withLock { streamUrlCache[trackId] = CachedStreamUrl( @@ -255,8 +246,7 @@ class StreamingUrlRepository( selectedStream = selectedStream, preferredFormat = preferredFormat, preferredQuality = preferredQuality, - pluginId = pluginId, - ) + pluginId = pluginId) } } @@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String internal fun selectPreferredAudioStream( streams: List, preferredFormat: AudioFormat, - preferredQuality: AudioQuality, -): AudioStream? = streams.minWithOrNull( + preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( compareBy { stream -> formatMismatchCount(stream, preferredFormat) }.thenBy { stream -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt deleted file mode 100644 index 670524a9..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt +++ /dev/null @@ -1,569 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash - -private val AutocompleteTextFieldMinHeight = 44.dp -private val AutocompleteMenuDefaultMaxHeight = 300.dp -private val AutocompleteMenuDefaultOffset = 4.dp - -private data class ResolvedAutoCompleteTextFieldState( - val background: Brush, - val highlight: Color, - val shape: Shape, - val border: BaseUITheme.Border, - val shadow: BaseUITheme.Shadow, - val foreground: Color, -) - -@Composable -private fun resolveTextFieldState( - theme: BaseUITheme.TextFieldTheme, - isFocused: Boolean, - isHovered: Boolean, -): ResolvedAutoCompleteTextFieldState { - return when { - isFocused -> ResolvedAutoCompleteTextFieldState( - theme.background.focused, - theme.highlight.focused, - theme.shape.focused, - theme.border.focused, - theme.shadow.focused, - theme.foreground.focused - ) - - isHovered -> ResolvedAutoCompleteTextFieldState( - theme.background.hovered, - theme.highlight.hovered, - theme.shape.hovered, - theme.border.hovered, - theme.shadow.hovered, - theme.foreground.hovered - ) - - else -> ResolvedAutoCompleteTextFieldState( - theme.background.normal, - theme.highlight.normal, - theme.shape.normal, - theme.border.normal, - theme.shadow.normal, - theme.foreground.normal - ) - } -} - -@Composable -fun AutocompleteTextField( - value: String, - onValueChange: (String) -> Unit, - items: List, - itemContent: @Composable (item: T, isSelected: Boolean) -> Unit, - modifier: Modifier = Modifier, - onItemSelected: (T) -> Unit = {}, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - label: @Composable (() -> Unit)? = null, - singleLine: Boolean = true, - maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, - enabled: Boolean = true, - readOnly: Boolean = false, - isError: Boolean = false, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - visualTransformation: VisualTransformation = VisualTransformation.None, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - textStyle: TextStyle = TextStyle.Default, - cursorBrush: Color? = null, - onKeyEvent: ((KeyEvent) -> Boolean)? = null, - menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight, - menuOffset: Dp = AutocompleteMenuDefaultOffset, - textFieldTheme: BaseUITheme.TextFieldTheme? = null, - menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null, -) { - val density = LocalDensity.current - val baseTextFieldTheme = LocalBaseUITheme.current.textField - val baseMenuTheme = LocalBaseUITheme.current.autoCompleteMenuTheme - val resolvedTextFieldTheme = textFieldTheme ?: baseTextFieldTheme - val resolvedMenuTheme = menuTheme ?: baseMenuTheme - val isFocused by interactionSource.collectIsFocusedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - - var isMenuOpen by remember { mutableStateOf(false) } - var selectedIndex by remember { mutableIntStateOf(-1) } - var textFieldSize by remember { mutableStateOf(IntSize.Zero) } - val menuListState = rememberLazyListState() - val textFieldFocusRequester = remember { FocusRequester() } - - val menuExpanded = isMenuOpen && items.isNotEmpty() - - LaunchedEffect(isFocused) { - if (isFocused) { - isMenuOpen = true - } - } - - LaunchedEffect(value) { - if (selectedIndex != -1) selectedIndex = -1 - } - - LaunchedEffect(selectedIndex) { - if (selectedIndex >= 0 && menuExpanded) { - menuListState.scrollToItem(selectedIndex) - } - } - - val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) { - KeyboardActions( - onSearch = { - if (menuExpanded && selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - } else { - keyboardActions.onSearch?.invoke(this) - } - }, - onDone = keyboardActions.onDone, - onGo = keyboardActions.onGo, - onNext = keyboardActions.onNext, - onPrevious = keyboardActions.onPrevious, - onSend = keyboardActions.onSend, - ) - } - - val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered) - - val resolvedBorder = if (isError) { - BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width) - } else { - fieldState.border - } - - val contentColor = when { - !enabled -> fieldState.foreground.copy(alpha = 0.38f) - else -> fieldState.foreground - } - - val cursor = cursorBrush?.let { SolidColor(it) } ?: resolvedTextFieldTheme.cursor - - fun navigate(delta: Int) { - if (items.isEmpty()) return - selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size - } - - Box(modifier = modifier) { - Column { - AnimatedVisibility( - visible = label != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - label?.invoke() - } - - Box( - modifier = Modifier - .fillMaxWidth() - .onSizeChanged { textFieldSize = it } - .defaultMinSize(minHeight = AutocompleteTextFieldMinHeight) - .then( - if (fieldState.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = fieldState.shadow.elevation, - shape = fieldState.shape, - ambientColor = fieldState.shadow.ambientColor, - spotColor = fieldState.shadow.spotColor, - ) - } else Modifier - ) - .clip(fieldState.shape) - .background(fieldState.background, fieldState.shape) - .border( - BorderStroke(resolvedBorder.width, resolvedBorder.color), - fieldState.shape - ) - .highlight(fieldState.highlight) - .padding(resolvedTextFieldTheme.padding), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (leadingIcon != null) { - leadingIcon() - } - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .hoverable( - interactionSource = interactionSource, - enabled = enabled, - ) - .focusRequester(textFieldFocusRequester) - .onFocusChanged { state -> - if (!state.isFocused) { - isMenuOpen = false - selectedIndex = -1 - } - } - .onPreviewKeyEvent { event -> - if (onKeyEvent?.invoke(event) == true) { - return@onPreviewKeyEvent true - } - if (!menuExpanded) return@onPreviewKeyEvent false - when (event.key) { - Key.DirectionDown -> { - navigate(1) - true - } - - Key.DirectionUp -> { - if (selectedIndex >= 0) { - navigate(-1) - true - } else false - } - - Key.Enter -> { - if (selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - return@onPreviewKeyEvent true - } - false - } - - Key.Escape -> { - selectedIndex = -1 - isMenuOpen = false - true - } - - else -> false - } - }, - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle.copy(color = contentColor), - cursorBrush = cursor, - keyboardOptions = keyboardOptions, - keyboardActions = wrappedKeyboardActions, - singleLine = singleLine, - maxLines = maxLines, - visualTransformation = visualTransformation, - interactionSource = interactionSource, - decorationBox = { innerTextField -> - if (value.isEmpty() && placeholder != null && !isFocused) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Box(contentAlignment = Alignment.CenterStart) { - placeholder() - } - } - } else { - innerTextField() - } - }, - ) - } - - if (trailingIcon != null) { - trailingIcon() - } - } - } - } - - if (menuExpanded && textFieldSize.width > 0) { - val textFieldHeightPx = textFieldSize.height - val menuOffsetPx = with(density) { menuOffset.roundToPx() } - val textFieldWidthDp = with(density) { textFieldSize.width.toDp() } - - Popup( - alignment = Alignment.TopStart, - offset = IntOffset(0, textFieldHeightPx + menuOffsetPx), - properties = PopupProperties(focusable = false), - onDismissRequest = { - selectedIndex = -1 - isMenuOpen = false - }, - ) { - AutocompleteMenuContainer( - theme = resolvedMenuTheme, - modifier = Modifier - .width(textFieldWidthDp) - .heightIn(max = menuMaxHeight), - ) { - LazyColumn( - state = menuListState, - modifier = Modifier - .heightIn(max = menuMaxHeight), - ) { - items(items.size) { index -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { - onItemSelected(items[index]) - selectedIndex = -1 - isMenuOpen = false - textFieldFocusRequester.requestFocus() - }, - ) { - itemContent(items[index], selectedIndex == index) - } - } - } - } - } - } - } -} - -/** - * The visual container of the autocomplete suggestion menu, without the Popup window. - * - * Extracted so the menu can be rendered in previews, since popups are hosted in a - * separate window the preview canvas cannot show. - */ -@Composable -internal fun AutocompleteMenuContainer( - theme: BaseUITheme.AutoCompleteMenuTheme, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - Box( - modifier = modifier - .shadow(theme.shadowElevation, theme.shape) - .background(theme.background, theme.shape) - .clip(theme.shape), - ) { - content() - } -} - -private val AutocompletePreviewArtists = listOf( - "Twenty One Pilots", - "The Beatles", - "Adele", - "Drake", - "Taylor Swift", - "Arctic Monkeys", - "Billie Eilish", -) - -@BaseUIPhonePreview -@Composable -private fun AutocompleteTextFieldPreview() { - BaseUIPreview { - var value by remember { mutableStateOf("") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - Text( - text = "Field states", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - AutocompleteTextField( - value = "", - onValueChange = {}, - items = emptyList(), - itemContent = { _, _ -> }, - placeholder = { Text("Search artists...") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - AutocompleteTextField( - value = value, - onValueChange = { value = it }, - items = AutocompletePreviewArtists.filter { - it.contains(value, ignoreCase = true) - }, - onItemSelected = { value = it }, - placeholder = { Text("Search artists...") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - }, - trailingIcon = { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Clear", - ) - }, - modifier = Modifier.fillMaxWidth(), - itemContent = { item, isSelected -> - AutocompletePreviewItem(item, isSelected) - }, - ) - } - } -} - -@BaseUIPhonePreview -@Composable -private fun AutocompleteMenuPreview() { - BaseUIPreview { - var selectedIndex by remember { mutableStateOf(1) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "Suggestion menu", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - AutocompleteMenuContainer( - theme = LocalBaseUITheme.current.autoCompleteMenuTheme, - modifier = Modifier.width(320.dp), - ) { - Column { - AutocompletePreviewArtists.take(5).forEachIndexed { index, artist -> - Box( - modifier = Modifier.clickable { selectedIndex = index }, - ) { - AutocompletePreviewItem(artist, isSelected = selectedIndex == index) - } - } - } - } - Text( - text = "Selected: ${AutocompletePreviewArtists[selectedIndex]}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun AutocompletePreviewItem(text: String, isSelected: Boolean) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = if (isSelected) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) - } else { - Color.Transparent - }, - ) - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt deleted file mode 100644 index cc588b44..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt +++ /dev/null @@ -1,381 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.luminance - -/** - * The app's own colour system, independent of [MaterialTheme]. - * - * Each [SurfaceStyle] is a complete visual theme with its own palette (backgrounds, - * surfaces, text and outlines) rather than Material's role colours. The user's chosen - * accent is threaded through as `primary`, but everything else comes from the surface - * theme, so the app never has to rely on Material's colour scheme. - */ -@Immutable -data class BaseUIColorScheme( - val surfaceTheme: SurfaceStyle, - val isLight: Boolean, - /** Full-bleed page background for the current surface theme. */ - val background: Brush, - val surface: Color, - val surfaceVariant: Color, - val surfaceContainer: Color, - val surfaceContainerHigh: Color, - val surfaceContainerHighest: Color, - val onBackground: Color, - val onSurface: Color, - val onSurfaceVariant: Color, - val primary: Color, - val onPrimary: Color, - val primaryContainer: Color, - val onPrimaryContainer: Color, - val secondaryContainer: Color, - val onSecondaryContainer: Color, - val backgroundPrimary: Color, - val onBackgroundPrimary: Color, - val backgroundSecondary: Color, - val onBackgroundSecondary: Color, - val accentPrimary: Color, - val onAccentPrimary: Color, - val accentSecondary: Color, - val onAccentSecondary: Color, - val backgroundInverse: Color, - val onBackgroundInverse: Color, - val actionInverse: Color, - val onActionInverse: Color, - val outline: Color, - val outlineVariant: Color, - val error: Color, - val onError: Color, - val scrim: Color, -) - -val LocalBaseUIColors = staticCompositionLocalOf { - error("No BaseUIColorScheme provided. Wrap your UI in a BaseUITheme provider.") -} - -/** - * Builds the [BaseUIColorScheme] for the selected [surfaceTheme] and accent. - * - * Light/dark is read from the surrounding [MaterialTheme] (which the app already drives - * from the user's theme setting), and the accent comes from the user's chosen accent - * colour. Everything else is derived from the surface theme palette. - */ -@Composable -fun rememberBaseUIColorScheme( - surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, - accentColor: Color? = null, -): BaseUIColorScheme { - val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val accent = accentColor ?: surfaceTheme.accentColor(isLight) - return remember(surfaceTheme, isLight, accent) { - baseUIColorScheme(surfaceTheme, isLight, accent) - } -} - -/** - * The default accent colour that characterises a surface theme. Every theme has its own, - * so a Watery app is aqua and a Chrome app is steel, unless an accent is passed explicitly. - */ -fun SurfaceStyle.accentColor(isLight: Boolean): Color = when (this) { - SurfaceStyle.Watery -> if (isLight) Color(0xFF0E8FD6) else Color(0xFF4FC3F7) - SurfaceStyle.Icy -> if (isLight) Color(0xFF3FA9E0) else Color(0xFF7FD8FF) - SurfaceStyle.Chrome -> if (isLight) Color(0xFF5B6B7C) else Color(0xFF9AA5B4) - SurfaceStyle.Acrylic -> if (isLight) Color(0xFF5C6BC0) else Color(0xFF8C9EFF) - SurfaceStyle.Glass -> if (isLight) Color(0xFF7C4DFF) else Color(0xFFB388FF) -} - -/** - * The secondary accent is derived from the primary accent as a complementary hue, so it always - * shifts with — and harmonises with — the chosen accent instead of being fixed per style. - */ -private fun Color.complementaryAccent(hueShift: Float = 180f): Color { - val (hue, saturation, value) = toHsv() - return Color.hsv( - (hue + hueShift + 360f) % 360f, - saturation.coerceIn(0.45f, 0.9f), - value.coerceIn(0.45f, 0.95f), - ) -} - -private fun Color.toHsv(): Triple { - val max = maxOf(red, green, blue) - val min = minOf(red, green, blue) - val delta = max - min - val rawHue = when { - delta == 0f -> 0f - max == red -> 60f * (((green - blue) / delta) % 6f) - max == green -> 60f * (((blue - red) / delta) + 2f) - else -> 60f * (((red - green) / delta) + 4f) - } - val hue = if (rawHue < 0f) rawHue + 360f else rawHue - val saturation = if (max == 0f) 0f else delta / max - return Triple(hue, saturation, max) -} - -fun baseUIColorScheme( - surfaceTheme: SurfaceStyle, - isLight: Boolean, - accent: Color, -): BaseUIColorScheme { - val palette = surfacePalette(surfaceTheme, isLight) - val onPrimary = if (accent.luminance() > 0.5f) Color(0xFF10161C) else Color.White - val primaryContainer = lerp(palette.surface, accent, if (isLight) 0.16f else 0.32f) - val onPrimaryContainer = if (isLight) { - lerp(accent, Color.Black, 0.55f) - } else { - lerp(accent, Color.White, 0.7f) - } - val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f) - val inversePalette = surfacePalette(surfaceTheme, !isLight) - val secondaryAccent = accent.complementaryAccent() - val onSecondaryAccent = if (secondaryAccent.luminance() > 0.5f) Color(0xFF10161C) else Color.White - val secondaryBackground = if (isLight) { - lerp(palette.surface, secondaryAccent, 0.14f) - } else { - lerp(palette.surface, secondaryAccent, 0.22f) - } - val onSecondaryBackground = if (secondaryBackground.luminance() > 0.5f) { - lerp(secondaryAccent, Color.Black, 0.6f) - } else { - lerp(secondaryAccent, Color.White, 0.82f) - } - val actionInverse = if (isLight) lerp(accent, Color.Black, 0.45f) else lerp(accent, Color.White, 0.5f) - val onActionInverse = if (actionInverse.luminance() > 0.5f) Color(0xFF10161C) else Color.White - - return BaseUIColorScheme( - surfaceTheme = surfaceTheme, - isLight = isLight, - background = Brush.verticalGradient( - listOf(palette.backgroundTop, palette.backgroundBottom), - ), - surface = palette.surface, - surfaceVariant = palette.surfaceVariant, - surfaceContainer = palette.surfaceContainer, - surfaceContainerHigh = palette.surfaceContainerHigh, - surfaceContainerHighest = palette.surfaceContainerHighest, - onBackground = palette.onBackground, - onSurface = palette.onSurface, - onSurfaceVariant = palette.onSurfaceVariant, - primary = accent, - onPrimary = onPrimary, - primaryContainer = primaryContainer, - onPrimaryContainer = onPrimaryContainer, - secondaryContainer = secondaryContainer, - onSecondaryContainer = palette.onSurface, - backgroundPrimary = palette.surface, - onBackgroundPrimary = palette.onSurface, - backgroundSecondary = secondaryBackground, - onBackgroundSecondary = onSecondaryBackground, - accentPrimary = accent, - onAccentPrimary = onPrimary, - accentSecondary = secondaryAccent, - onAccentSecondary = onSecondaryAccent, - backgroundInverse = inversePalette.surface, - onBackgroundInverse = inversePalette.onSurface, - actionInverse = actionInverse, - onActionInverse = onActionInverse, - outline = palette.outline, - outlineVariant = palette.outlineVariant, - error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373), - onError = Color.White, - scrim = Color.Black.copy(alpha = 0.42f), - ) -} - -internal class SurfacePalette( - val backgroundTop: Color, - val backgroundBottom: Color, - val surface: Color, - val surfaceVariant: Color, - val surfaceContainer: Color, - val surfaceContainerHigh: Color, - val surfaceContainerHighest: Color, - val onBackground: Color, - val onSurface: Color, - val onSurfaceVariant: Color, - val outline: Color, - val outlineVariant: Color, -) - -internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { - return if (isLight) { - when (style) { - SurfaceStyle.Glass -> SurfacePalette( - backgroundTop = Color(0xFFEAF0F7), - backgroundBottom = Color(0xFFD8E3F1), - surface = Color(0xFFF8FBFF), - surfaceVariant = Color(0xFFE4ECF5), - surfaceContainer = Color(0xFFEEF3F9), - surfaceContainerHigh = Color(0xFFF3F7FC), - surfaceContainerHighest = Color(0xFFE1E9F3), - onBackground = Color(0xFF1B2733), - onSurface = Color(0xFF1B2733), - onSurfaceVariant = Color(0xFF56677A), - outline = Color(0xFFA9B8C8), - outlineVariant = Color(0xFFD3DDE8), - ) - - SurfaceStyle.Acrylic -> SurfacePalette( - backgroundTop = Color(0xFFE6EAF0), - backgroundBottom = Color(0xFFD6DCE5), - surface = Color(0xFFF4F7FA), - surfaceVariant = Color(0xFFE1E7EE), - surfaceContainer = Color(0xFFEAEEF4), - surfaceContainerHigh = Color(0xFFF1F4F8), - surfaceContainerHighest = Color(0xFFDCE2E9), - onBackground = Color(0xFF1C222B), - onSurface = Color(0xFF1C222B), - onSurfaceVariant = Color(0xFF545E6C), - outline = Color(0xFFA6AEBC), - outlineVariant = Color(0xFFD2D8E0), - ) - - SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFFEAF7FE), - backgroundBottom = Color(0xFFD8EFFB), - surface = Color(0xFFF0FAFF), - surfaceVariant = Color(0xFFE2F2FB), - surfaceContainer = Color(0xFFEAF6FD), - surfaceContainerHigh = Color(0xFFF2FAFE), - surfaceContainerHighest = Color(0xFFD8ECF8), - onBackground = Color(0xFF08324A), - onSurface = Color(0xFF08324A), - onSurfaceVariant = Color(0xFF3A6884), - outline = Color(0xFF9EC4DA), - outlineVariant = Color(0xFFD0E7F4), - ) - - SurfaceStyle.Icy -> SurfacePalette( - backgroundTop = Color(0xFFE8F7FF), - backgroundBottom = Color(0xFFC9E8FB), - surface = Color(0xFFF6FCFF), - surfaceVariant = Color(0xFFE0F1FC), - surfaceContainer = Color(0xFFEBF7FE), - surfaceContainerHigh = Color(0xFFF3FBFF), - surfaceContainerHighest = Color(0xFFD4EBFA), - onBackground = Color(0xFF0B3A55), - onSurface = Color(0xFF0B3A55), - onSurfaceVariant = Color(0xFF3D6C88), - outline = Color(0xFF93C0D9), - outlineVariant = Color(0xFFC9E5F4), - ) - - SurfaceStyle.Chrome -> SurfacePalette( - backgroundTop = Color(0xFFE4E8ED), - backgroundBottom = Color(0xFFD0D6DE), - surface = Color(0xFFF4F7FA), - surfaceVariant = Color(0xFFE0E5EB), - surfaceContainer = Color(0xFFE9EDF2), - surfaceContainerHigh = Color(0xFFF1F4F7), - surfaceContainerHighest = Color(0xFFDBE0E7), - onBackground = Color(0xFF1B2733), - onSurface = Color(0xFF1B2733), - onSurfaceVariant = Color(0xFF525C6A), - outline = Color(0xFFA2AAB6), - outlineVariant = Color(0xFFD0D5DC), - ) - } - } else { - when (style) { - SurfaceStyle.Glass -> SurfacePalette( - backgroundTop = Color(0xFF0E1217), - backgroundBottom = Color(0xFF161C24), - surface = Color(0xFF1E242C), - surfaceVariant = Color(0xFF262E38), - surfaceContainer = Color(0xFF1A2027), - surfaceContainerHigh = Color(0xFF232A33), - surfaceContainerHighest = Color(0xFF2C343E), - onBackground = Color(0xFFE6EDF5), - onSurface = Color(0xFFE6EDF5), - onSurfaceVariant = Color(0xFF9AA7B6), - outline = Color(0xFF46525F), - outlineVariant = Color(0xFF2C343E), - ) - - SurfaceStyle.Acrylic -> SurfacePalette( - backgroundTop = Color(0xFF101317), - backgroundBottom = Color(0xFF181C22), - surface = Color(0xFF20262E), - surfaceVariant = Color(0xFF282F39), - surfaceContainer = Color(0xFF1C2229), - surfaceContainerHigh = Color(0xFF252C35), - surfaceContainerHighest = Color(0xFF2E3640), - onBackground = Color(0xFFE4EAF1), - onSurface = Color(0xFFE4EAF1), - onSurfaceVariant = Color(0xFF98A2B0), - outline = Color(0xFF48525E), - outlineVariant = Color(0xFF2E3640), - ) - - SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFF0A1622), - backgroundBottom = Color(0xFF0E1E2D), - surface = Color(0xFF0E2233), - surfaceVariant = Color(0xFF153148), - surfaceContainer = Color(0xFF0B1D2C), - surfaceContainerHigh = Color(0xFF12293C), - surfaceContainerHighest = Color(0xFF183449), - onBackground = Color(0xFFD6EAF7), - onSurface = Color(0xFFD6EAF7), - onSurfaceVariant = Color(0xFF8FB0C6), - outline = Color(0xFF3C5A72), - outlineVariant = Color(0xFF183449), - ) - - SurfaceStyle.Icy -> SurfacePalette( - backgroundTop = Color(0xFF0C1620), - backgroundBottom = Color(0xFF132331), - surface = Color(0xFF16283A), - surfaceVariant = Color(0xFF1E3448), - surfaceContainer = Color(0xFF122130), - surfaceContainerHigh = Color(0xFF1A2E42), - surfaceContainerHighest = Color(0xFF213A50), - onBackground = Color(0xFFDCEBF7), - onSurface = Color(0xFFDCEBF7), - onSurfaceVariant = Color(0xFF92B3CA), - outline = Color(0xFF40607A), - outlineVariant = Color(0xFF213A50), - ) - - SurfaceStyle.Chrome -> SurfacePalette( - backgroundTop = Color(0xFF101317), - backgroundBottom = Color(0xFF181C22), - surface = Color(0xFF232830), - surfaceVariant = Color(0xFF2B313A), - surfaceContainer = Color(0xFF1E232A), - surfaceContainerHigh = Color(0xFF272D36), - surfaceContainerHighest = Color(0xFF303742), - onBackground = Color(0xFFE7ECF2), - onSurface = Color(0xFFE7ECF2), - onSurfaceVariant = Color(0xFF9BA5B2), - outline = Color(0xFF4B5561), - outlineVariant = Color(0xFF303742), - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt deleted file mode 100644 index 7ce9a6f2..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview - -/** - * Shared scaffold for every preview of a base UI component. - * - * It mirrors the runtime environment the components expect: - * - a [MaterialTheme] whose light/dark mode follows the preview's `uiMode`, so the - * `@PreviewLightDark`-style annotations actually change the rendered colors, - * - a [LocalBaseUITheme] built from that color scheme via [rememberBaseUITheme], - * - a [Surface] matching the real app background, so layered highlights and shadows - * render against the color they will actually sit on. - * - * Always render base components inside [BaseUIPreview]; several of them read - * [LocalBaseUITheme] directly and would otherwise throw at composition time. - */ -@Composable -internal fun BaseUIPreview( - modifier: Modifier = Modifier, - surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, - content: @Composable BoxScope.() -> Unit, -) { - MaterialTheme( - colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(), - ) { - val theme = rememberBaseUITheme(surfaceTheme = surfaceTheme) - CompositionLocalProvider( - LocalBaseUITheme provides theme, - LocalBaseUIColors provides theme.colors, - ) { - Surface( - modifier = modifier.fillMaxSize(), - color = theme.colors.surface, - ) { - Box(modifier = Modifier.fillMaxSize(), content = content) - } - } - } -} - -/** - * Renders a base UI component on a phone-sized canvas in both light and dark themes. - */ -@Preview(name = "Phone · Light", group = "Base UI devices", device = Devices.PHONE) -@Preview( - name = "Phone · Dark", - group = "Base UI devices", - device = Devices.PHONE, - uiMode = UI_MODE_NIGHT_YES, -) -internal annotation class BaseUIPhonePreview - -/** - * Renders a base UI component on a desktop-sized canvas in both light and dark themes. - */ -@Preview(name = "Desktop · Light", group = "Base UI devices", device = Devices.DESKTOP) -@Preview( - name = "Desktop · Dark", - group = "Base UI devices", - device = Devices.DESKTOP, - uiMode = UI_MODE_NIGHT_YES, -) -internal annotation class BaseUIDesktopPreview diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt deleted file mode 100644 index daccca41..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ /dev/null @@ -1,908 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare -import dev.krtirtho.spotube.resources.iconsax.User - -private val BadgeShape = RoundedCornerShape(11.dp) -private const val DisabledContentAlpha = 0.38f - -private val ButtonMinHeight = 40.dp -private val SquareButtonSize = 40.dp - -private data class ResolvedButtonState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, - val padding: PaddingValues, -) - -@Composable -private fun resolveButtonState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedButtonState { - return when { - isPressed -> ResolvedButtonState( - style.colors.pressed, - style.shape.pressed, - style.shadow.pressed, - style.border.pressed, - style.padding.pressed - ) - - isHovered -> ResolvedButtonState( - style.colors.hovered, - style.shape.hovered, - style.shadow.hovered, - style.border.hovered, - style.padding.hovered - ) - - else -> ResolvedButtonState( - style.colors.normal, - style.shape.normal, - style.shadow.normal, - style.border.normal, - style.padding.normal - ) - } -} - -private fun Modifier.applyShadow( - shadow: BaseUITheme.Shadow, - shape: Shape, -): Modifier { - return if (shadow.elevation > 0.dp) { - this.shadow( - elevation = shadow.elevation, - shape = shape, - ambientColor = shadow.ambientColor, - spotColor = shadow.spotColor, - ) - } else { - this - } -} - -/** - * Overlays the ambient [SurfaceStyle] material for a colour's [BaseUITheme.ButtonColors.surfaceRole], - * so an actionable item looks exactly like the matching [BaseSurface] role. - */ -@Composable -internal fun Modifier.applySurfaceMaterial(colors: BaseUITheme.ButtonColors): Modifier = - applySurfaceMaterial(colors.surfaceRole) - -@Composable -internal fun Modifier.applySurfaceMaterial(role: SurfaceRole?): Modifier { - val scheme = LocalBaseUIColors.current - return role?.let { baseSurfaceTexture(scheme.surfaceTheme, it, scheme.isLight) } ?: this -} - -/** - * The generic button gloss. Textured items get their sheen from the surface material, so it - * is only applied to colours without a [BaseUITheme.ButtonColors.surfaceRole]. - */ -internal fun Modifier.applyHighlight(colors: BaseUITheme.ButtonColors): Modifier = - if (colors.surfaceRole == null) highlight(colors.highlight) else this - -@Composable -fun OutlineButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - hoverOnly: Boolean = false, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .then( - if (hoverOnly && !isHovered) Modifier else Modifier.applyShadow( - state.shadow, - state.shape - ) - ) - .clip(state.shape) - .then( - if (hoverOnly && !isHovered) Modifier else Modifier.background( - state.colors.background, - state.shape - ) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - ) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .then(if (hoverOnly && !isHovered) Modifier else Modifier.applyHighlight(state.colors)) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - Row( - modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - content() - } - }, - ) - } -} - -@Composable -fun PrimaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.primary - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .applyShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .applyHighlight(state.colors) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - Row( - modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } - } -} - -@Composable -fun SecondaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.secondary - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .applyShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .applyHighlight(state.colors) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - Row( - modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } - } -} - -@Composable -fun IconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.outline - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun GhostButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.ghost - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - hoverOnly = true, - theme = style, - content = content, - ) -} - -@Composable -fun GhostIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.ghost - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - hoverOnly = true, - theme = style, - ) { - content() - } -} - -@Composable -fun PrimaryIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.primary - PrimaryButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun SecondaryIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.secondary - SecondaryButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun ButtonBadge( - count: Int, - modifier: Modifier = Modifier, - theme: BaseUITheme.ButtonStyle? = null, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - Box( - modifier = modifier - .heightIn(min = 22.dp) - .defaultMinSize(minWidth = 22.dp) - .background(state.colors.background, BadgeShape) - .border(1.dp, state.border.color, BadgeShape) - .padding(horizontal = 7.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = count.toString(), - fontSize = 12.sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - maxLines = 1, - softWrap = false, - color = state.colors.foreground.copy(alpha = 0.7f), - ) - } -} - -@Composable -fun GroupButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val overlay = when { - isPressed -> state.colors.background - isHovered -> state.colors.background - else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) - } - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .graphicsLayer { translationY = lift.toPx() } - .background(overlay) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } -} - -@Composable -fun GroupIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val overlay = when { - isPressed -> state.colors.background - isHovered -> state.colors.background - else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) - } - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .size(SquareButtonSize) - .graphicsLayer { translationY = lift.toPx() } - .background(overlay) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .alpha(if (enabled) 1f else DisabledContentAlpha), - contentAlignment = Alignment.Center, - ) { - content() - } - } -} - -@Composable -fun ButtonGroup( - modifier: Modifier = Modifier, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - val groupShape = state.shape - Surface( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .applyShadow(state.shadow, groupShape), - shape = groupShape, - color = Color.Transparent, - border = BorderStroke(state.border.width, state.border.color), - ) { - Box( - modifier = Modifier.background(state.colors.background, groupShape), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - content() - } - } - } -} - -@Composable -fun ButtonGroupDivider( - theme: BaseUITheme.ButtonStyle? = null, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - Box( - modifier = Modifier - .width(1.dp) - .heightIn(min = 20.dp) - .background(state.border.color), - ) -} - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun ButtonsPreview() { - BaseUIPreview { - var liked by remember { mutableStateOf(false) } - var likeCount by remember { mutableIntStateOf(128) } - var following by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Share", maxLines = 1, softWrap = false) - } - SecondaryButton(onClick = {}) { - Text("Secondary", maxLines = 1, softWrap = false) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - PrimaryButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text( - "Sign up", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - GhostButton(onClick = {}) { - Text("Ghost", maxLines = 1, softWrap = false) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}, enabled = false) { - Text("Disabled", maxLines = 1, softWrap = false) - } - SecondaryButton(onClick = {}, enabled = false) { - Text("Disabled", maxLines = 1, softWrap = false) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - PrimaryButton(onClick = {}, enabled = false) { - Text("Disabled", maxLines = 1, softWrap = false) - } - OutlineButton(onClick = {}) { - Text("Forward", maxLines = 1, softWrap = false) - Icon( - imageVector = Iconsax.IconsaxArrowSquareUp, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton( - onClick = { - liked = !liked - likeCount += if (liked) 1 else -1 - }, - ) { - Icon( - imageVector = Iconsax.IconsaxHeart, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text(if (liked) "Liked" else "Like", maxLines = 1, softWrap = false) - ButtonBadge(count = likeCount) - } - PrimaryButton(onClick = { following = !following }) { - Text( - if (following) "Following" else "Follow", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - } - } - } -} - -@BaseUIPhonePreview -@Composable -private fun IconButtonsPreview() { - BaseUIPreview { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "Icon buttons", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - IconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxMagic, - contentDescription = "Magic", - modifier = Modifier.size(18.dp), - ) - } - GhostIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxHeart, - contentDescription = "Favorite", - modifier = Modifier.size(18.dp), - ) - } - PrimaryIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add", - modifier = Modifier.size(18.dp), - ) - } - SecondaryIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = "Share", - modifier = Modifier.size(18.dp), - ) - } - } - - Text( - text = "Variable icon button", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - VariableIconButton( - variant = VariableIconButtonVariant.Outline, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.IconsaxDocumentText, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - VariableIconButton( - variant = VariableIconButtonVariant.Ghost, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - VariableIconButton( - variant = VariableIconButtonVariant.Primary, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.IconsaxMagic, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - VariableIconButton( - variant = VariableIconButtonVariant.Secondary, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.User, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - } - } -} - -@BaseUIDesktopPreview -@Composable -private fun ButtonGroupPreview() { - BaseUIPreview { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - Text( - text = "Button groups", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - ButtonGroup { - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxDocumentText, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Documents", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Export", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - ButtonGroup { - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.ArrowLeft3, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - ButtonBadge(count = 8) - } - } - } -} - -enum class VariableIconButtonVariant { - Outline, - Ghost, - Primary, - Secondary, -} - -@Composable -fun VariableIconButton( - variant: VariableIconButtonVariant = VariableIconButtonVariant.Outline, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - when (variant) { - VariableIconButtonVariant.Outline -> IconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Ghost -> GhostIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Primary -> PrimaryIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Secondary -> SecondaryIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt deleted file mode 100644 index 91802df8..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay - -@Composable -fun Card( - modifier: Modifier = Modifier, - theme: BaseUITheme.CardTheme? = null, - content: @Composable () -> Unit, -) { - val cardTheme = theme ?: LocalBaseUITheme.current.card - - Box( - modifier = modifier - .then( - if (cardTheme.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = cardTheme.shadow.elevation, - shape = cardTheme.shape, - ambientColor = cardTheme.shadow.ambientColor, - spotColor = cardTheme.shadow.spotColor, - ) - } else Modifier - ) - .clip(cardTheme.shape) - .background(cardTheme.background, cardTheme.shape) - .applySurfaceMaterial(cardTheme.surfaceRole) - .border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape) - .padding(cardTheme.padding), - ) { - content() - } -} - -@BaseUIPhonePreview -@Composable -private fun CardPreview() { - BaseUIPreview { - var connected by remember { mutableStateOf(false) } - var playing by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Card { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = if (connected) "Spotify connected" else "Connect a music source", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(6.dp)) - Text( - text = if (connected) { - "Streaming, downloads and scrobbling are ready." - } else { - "Sign in to stream, download and scrobble from your account." - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - OutlineButton(onClick = { connected = false }) { - Text("Disconnect") - } - PrimaryButton(onClick = { connected = !connected }) { - Text(if (connected) "Connected" else "Connect") - } - } - } - } - - Card { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(44.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Iconsax.IconsaxMusic, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Midnight City", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - ) - Text( - text = "M83 · Hurry Up, We're Dreaming", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - PrimaryIconButton(onClick = { playing = !playing }) { - Icon( - imageVector = if (playing) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playing) "Pause" else "Play", - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt deleted file mode 100644 index b037e565..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt +++ /dev/null @@ -1,281 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -enum class CheckBoxState { - SELECTED, - UNSELECTED, - INDETERMINATE, - @Deprecated("Use INDETERMINATE", ReplaceWith("CheckBoxState.INDETERMINATE")) - CLEAR; -} - -private val CheckBoxSize = 22.dp - -private data class ResolvedCheckBoxState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveCheckBoxState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedCheckBoxState { - return when { - isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) - isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered) - else -> ResolvedCheckBoxState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal) - } -} - -@Composable -fun CheckBox( - state: CheckBoxState = CheckBoxState.UNSELECTED, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - theme: BaseUITheme.CheckBoxTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.checkBox - val checkBoxTheme = theme ?: baseTheme - val source = interactionSource ?: remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val isSelected = state == CheckBoxState.SELECTED - val isIndeterminate = state == CheckBoxState.INDETERMINATE - val isFilled = isSelected || isIndeterminate - - val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected - val resolved = resolveCheckBoxState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp - - val borderColor = when { - !enabled -> resolved.colors.foreground.copy(alpha = 0.38f) - else -> resolved.border.color - } - - val checkProgress by animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, - animationSpec = tween(durationMillis = 180), - label = "checkProgress", - ) - val dashProgress by animateFloatAsState( - targetValue = if (isIndeterminate) 1f else 0f, - animationSpec = tween(durationMillis = 150), - label = "dashProgress", - ) - - Box( - modifier = modifier - .size(CheckBoxSize) - .graphicsLayer { translationY = lift.toPx() } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = resolved.shape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(resolved.shape) - .background(resolved.colors.background, resolved.shape) - .applySurfaceMaterial(resolved.colors) - .border(BorderStroke(0.5.dp, borderColor), resolved.shape) - .applyHighlight(resolved.colors) - .then( - if (onClick != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - interactionSource = source, - indication = ripple(), - enabled = enabled, - onClick = onClick, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center, - ) { - if (isSelected) { - Box( - modifier = Modifier - .size(14.dp) - .graphicsLayer { alpha = checkProgress }, - ) { - androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) { - val stroke = 2.dp.toPx() - val path = Path().apply { - val w = size.width - val h = size.height - moveTo(w * 0.2f, h * 0.52f) - lineTo(w * 0.42f, h * 0.74f) - lineTo(w * 0.82f, h * 0.28f) - } - drawPath( - path = path, - color = checkBoxTheme.checkmarkColor, - style = Stroke( - width = stroke, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) - } - } - } else if (isIndeterminate) { - Box( - modifier = Modifier - .size(width = 10.dp, height = 2.dp) - .graphicsLayer { alpha = dashProgress } - .clip(RoundedCornerShape(1.dp)) - .background( - brush = Brush.horizontalGradient( - colors = listOf( - checkBoxTheme.checkmarkColor, - checkBoxTheme.checkmarkColor.copy(alpha = 0.92f), - ) - ), - ), - ) - } - } -} - -@BaseUIPhonePreview -@Composable -private fun CheckBoxPreview() { - BaseUIPreview { - var wifi by remember { mutableStateOf(true) } - var bluetooth by remember { mutableStateOf(false) } - var downloads by remember { mutableStateOf(true) } - - val allEnabled = wifi && bluetooth && downloads - val anyEnabled = wifi || bluetooth || downloads - val allState = when { - allEnabled -> CheckBoxState.SELECTED - anyEnabled -> CheckBoxState.INDETERMINATE - else -> CheckBoxState.UNSELECTED - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - CheckBoxPreviewRow( - state = allState, - label = "All playback sources", - onClick = { - val target = !allEnabled - wifi = target - bluetooth = target - downloads = target - }, - ) - Spacer(Modifier.height(4.dp)) - CheckBoxPreviewRow( - state = if (wifi) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - label = "Wi-Fi", - onClick = { wifi = !wifi }, - ) - CheckBoxPreviewRow( - state = if (bluetooth) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - label = "Bluetooth", - onClick = { bluetooth = !bluetooth }, - ) - CheckBoxPreviewRow( - state = if (downloads) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - label = "Download over cellular", - onClick = { downloads = !downloads }, - ) - CheckBoxPreviewRow(CheckBoxState.SELECTED, "Disabled selected", enabled = false) - CheckBoxPreviewRow(CheckBoxState.UNSELECTED, "Disabled unselected", enabled = false) - } - } -} - -@Composable -private fun CheckBoxPreviewRow( - state: CheckBoxState, - label: String, - enabled: Boolean = true, - onClick: () -> Unit = {}, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = state, onClick = onClick, enabled = enabled) - Text(label, style = MaterialTheme.typography.bodyMedium) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt deleted file mode 100644 index 98a34762..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary - -private val ChipTabMinHeight = 36.dp - -private data class ResolvedChipState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, - val padding: androidx.compose.foundation.layout.PaddingValues, -) - -@Composable -private fun resolveChipState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedChipState { - return when { - isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed) - isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered) - else -> ResolvedChipState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal, style.padding.normal) - } -} - -private fun Modifier.applyChipShadow( - shadow: BaseUITheme.Shadow, - shape: Shape, -): Modifier { - return if (shadow.elevation > 0.dp) { - this.shadow( - elevation = shadow.elevation, - shape = shape, - ambientColor = shadow.ambientColor, - spotColor = shadow.spotColor, - ) - } else { - this - } -} - -@Composable -fun ChipTab( - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ChipTabTheme? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current.chipTab - val chipTheme = theme ?: baseTheme - val style = if (selected) chipTheme.selected else chipTheme.unselected - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveChipState(style, isPressed, isHovered) - - Box( - modifier = modifier - .defaultMinSize(minHeight = ChipTabMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .applyChipShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .applyHighlight(state.colors) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider( - LocalContentColor provides state.colors.foreground, - LocalTextStyle provides LocalTextStyle.current.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - content = content, - ) - } - } -} - -@Composable -fun ChipTab( - text: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: @Composable (() -> Unit)? = null, - theme: BaseUITheme.ChipTabTheme? = null, -) { - ChipTab( - selected = selected, - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - ) { - if (leadingIcon != null) { - leadingIcon() - } - Text( - text = text, - maxLines = 1, - softWrap = false, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun ChipTabsPreview() { - BaseUIPreview { - val tabs = listOf("All", "Songs", "Albums", "Playlists") - var selected by remember { mutableStateOf(0) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - tabs.forEachIndexed { index, tab -> - ChipTab( - text = tab, - selected = selected == index, - onClick = { selected = index }, - ) - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ChipTab( - text = "Library", - selected = true, - onClick = {}, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxMusicLibrary, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - ) - ChipTab( - text = "Downloads", - selected = false, - onClick = {}, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxDocumentDownload, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - ) - ChipTab( - text = "Disabled", - selected = false, - onClick = {}, - enabled = false, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt deleted file mode 100644 index b7cd5613..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -private val DefaultDialogMaxWidth: Dp = 440.dp - -@Composable -fun ThemedDialog( - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - title: @Composable (() -> Unit)? = null, - actions: @Composable (() -> Unit)? = null, - theme: BaseUITheme.DialogTheme? = null, - properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), - content: @Composable () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - properties = properties, - ) { - AnimatedVisibility( - visible = true, - enter = scaleIn( - initialScale = 0.92f, - animationSpec = tween(280), - ) + fadeIn(animationSpec = tween(200)), - exit = scaleOut( - targetScale = 0.92f, - animationSpec = tween(180), - ) + fadeOut(animationSpec = tween(150)), - ) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - ThemedDialogSurface( - theme = theme, - title = title, - actions = actions, - content = content, - ) - } - } - } -} - -/** - * The visual chrome of [ThemedDialog] without the platform [Dialog] window. - * - * Extracted so the dialog can be rendered inside previews (and reused elsewhere), - * since the platform dialog lives in a separate window the preview canvas cannot show. - */ -@Composable -internal fun ThemedDialogSurface( - modifier: Modifier = Modifier, - theme: BaseUITheme.DialogTheme? = null, - title: @Composable (() -> Unit)? = null, - actions: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { - val dialogTheme = theme ?: LocalBaseUITheme.current.dialog - - Box( - modifier = modifier - .widthIn(max = DefaultDialogMaxWidth) - .shadow( - elevation = dialogTheme.shadow.elevation, - shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, - ambientColor = dialogTheme.shadow.ambientColor, - spotColor = dialogTheme.shadow.spotColor, - ) - .clip(dialogTheme.shape) - .background(dialogTheme.background, dialogTheme.shape) - .border( - BorderStroke(dialogTheme.border.width, dialogTheme.border.color), - dialogTheme.shape - ) - .highlight(Color.White.copy(alpha = 0.08f)), - ) { - Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { - if (title != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp, top = 24.dp), - ) { - title() - } - Spacer(Modifier.height(8.dp)) - } - - Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()) - .padding( - start = 24.dp, - end = 24.dp, - top = if (title == null) 24.dp else 0.dp, - ), - ) { - content() - } - - if (actions != null) { - Spacer(Modifier.height(16.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - actions() - } - } - } else { - Spacer(Modifier.height(24.dp)) - } - } - } -} - -@BaseUIPhonePreview -@Composable -private fun ThemedDialogPreview() { - BaseUIPreview { - var visible by remember { mutableStateOf(true) } - - Box(modifier = Modifier.fillMaxSize()) { - PrimaryButton( - onClick = { visible = true }, - modifier = Modifier - .align(Alignment.TopStart) - .padding(24.dp), - ) { - Text("Open dialog") - } - - if (visible) { - Box( - modifier = Modifier - .fillMaxSize() - .background(LocalBaseUITheme.current.dialog.scrim) - .clickable { visible = false }, - contentAlignment = Alignment.Center, - ) { - ThemedDialogSurface( - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = {}, - ), - title = { - Text( - text = "Sign out of Spotube?", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - OutlineButton(onClick = { visible = false }) { Text("Cancel") } - PrimaryButton(onClick = { visible = false }) { Text("Sign out") } - }, - ) { - Text( - text = "You can sign back in at any time. Your downloaded tracks stay on this device.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt deleted file mode 100644 index 20f9af7f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt +++ /dev/null @@ -1,277 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash - -@Composable -fun DropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 4.dp), - theme: BaseUITheme.DropdownMenuTheme? = null, - content: @Composable ColumnScope.() -> Unit, -) { - val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu - - androidx.compose.material3.DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - offset = offset, - modifier = modifier.dropdownMenuContainer(menuTheme), - ) { - content() - } -} - -/** - * The container styling of [DropdownMenu], split out so it can be reused by the - * material3 popup and by previews, which cannot render the popup window itself. - */ -internal fun Modifier.dropdownMenuContainer(theme: BaseUITheme.DropdownMenuTheme): Modifier = - this - .widthIn(min = 180.dp) - .shadow( - elevation = theme.shadow.elevation, - shape = theme.shape, - ambientColor = theme.shadow.ambientColor, - spotColor = theme.shadow.spotColor, - ) - .clip(theme.shape) - .background(theme.background, theme.shape) - .border( - BorderStroke(theme.border.width, theme.border.color), - theme.shape, - ) - -@Composable -fun DropdownMenuItem( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: ImageVector? = null, - leadingContent: (@Composable () -> Unit)? = null, - subtitle: String? = null, - selected: Boolean = false, - theme: BaseUITheme.DropdownMenuTheme? = null, -) { - val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - - Row( - modifier = modifier - .fillMaxWidth() - .clip(menuTheme.shape) - .hoverable(interactionSource) - .then( - if (isHovered && enabled) { - Modifier.background(menuTheme.itemHoverBackground) - } else { - Modifier - } - ) - .then( - if (isHovered && enabled) { - Modifier.highlight(menuTheme.itemHighlight) - } else { - Modifier - } - ) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - .padding(menuTheme.itemPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - when { - leadingContent != null -> leadingContent() - leadingIcon != null -> Icon( - imageVector = leadingIcon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (enabled) { - menuTheme.itemForeground - } else { - menuTheme.itemForeground.copy(alpha = 0.38f) - }, - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = if (enabled) { - menuTheme.itemForeground - } else { - menuTheme.itemForeground.copy(alpha = 0.38f) - }, - maxLines = 1, - softWrap = false, - ) - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = menuTheme.itemForeground.copy(alpha = if (enabled) 0.6f else 0.38f), - maxLines = 1, - softWrap = false, - ) - } - } - } -} - -@Composable -fun DropdownMenuDivider( - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun DropdownMenuPreview() { - BaseUIPreview { - var expanded by remember { mutableStateOf(true) } - var lastAction by remember { mutableStateOf("None") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - OutlineButton(onClick = { expanded = !expanded }) { - Text(if (expanded) "Hide menu" else "Show menu") - } - - if (expanded) { - Column( - modifier = Modifier.dropdownMenuContainer( - LocalBaseUITheme.current.dropdownMenu, - ), - ) { - DropdownMenuItem( - text = "Play next", - leadingIcon = Iconsax.IconsaxPlay, - onClick = { - lastAction = "Play next" - expanded = false - }, - ) - DropdownMenuItem( - text = "Add to queue", - leadingIcon = Iconsax.IconsaxAddSquare, - onClick = { - lastAction = "Add to queue" - expanded = false - }, - ) - DropdownMenuItem( - text = "Add to favorites", - leadingIcon = Iconsax.IconsaxHeart, - selected = lastAction == "Add to favorites", - onClick = { - lastAction = "Add to favorites" - expanded = false - }, - ) - DropdownMenuDivider() - DropdownMenuItem( - text = "Share", - leadingIcon = Iconsax.IconsaxShare, - onClick = { - lastAction = "Share" - expanded = false - }, - ) - DropdownMenuItem( - text = "Remove from playlist", - leadingIcon = Iconsax.IconsaxTrash, - enabled = false, - onClick = {}, - ) - } - } - - Text( - text = "Last action: $lastAction", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt deleted file mode 100644 index 6111f2a4..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic - -@Composable -fun ListRowTile( - selected: Boolean = false, - enabled: Boolean = true, - onClick: (() -> Unit)? = null, - modifier: Modifier = Modifier, - leading: @Composable (() -> Unit)? = null, - title: @Composable (() -> Unit)? = null, - subtitle: @Composable (() -> Unit)? = null, - trailing: @Composable (() -> Unit)? = null, -) { - val rowTheme = LocalBaseUITheme.current.listRowTile - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val isFocused by interactionSource.collectIsFocusedAsState() - - val background = when { - isPressed -> rowTheme.background.pressed - isHovered -> rowTheme.background.hovered - isFocused -> rowTheme.background.focused - selected -> rowTheme.background.selected - else -> rowTheme.background.normal - } - val shape = when { - isPressed -> rowTheme.shape.pressed - isHovered -> rowTheme.shape.hovered - isFocused -> rowTheme.shape.focused - selected -> rowTheme.shape.selected - else -> rowTheme.shape.normal - } - val borderDef = when { - isPressed -> rowTheme.border.pressed - isHovered -> rowTheme.border.hovered - isFocused -> rowTheme.border.focused - selected -> rowTheme.border.selected - else -> rowTheme.border.normal - } - val shadowDef = when { - isPressed -> rowTheme.shadow.pressed - isHovered -> rowTheme.shadow.hovered - isFocused -> rowTheme.shadow.focused - selected -> rowTheme.shadow.selected - else -> rowTheme.shadow.normal - } - - val mod = modifier - .fillMaxWidth() - .shadow( - shadowDef.elevation, - shape, - shadowDef.clip, - shadowDef.ambientColor, - shadowDef.spotColor - ) - .clip(shape) - .border(borderDef.width, borderDef.color, shape) - .background(background, shape) - .then( - if (onClick != null) { - Modifier.clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - } else Modifier - ) - .padding(rowTheme.padding) - - Row( - modifier = mod, - verticalAlignment = Alignment.CenterVertically, - ) { - if (leading != null) { - leading() - Spacer(Modifier.width(12.dp)) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - if (title != null) { - title() - } - if (subtitle != null) { - Spacer(Modifier.width(2.dp)) - subtitle() - } - } - if (trailing != null) { - Spacer(Modifier.width(12.dp)) - trailing() - } - } -} - -private data class ListRowPreviewTrack( - val title: String, - val subtitle: String, - val duration: String, -) - -private val ListRowPreviewTracks = listOf( - ListRowPreviewTrack("Midnight City", "M83 · Hurry Up, We're Dreaming", "4:03"), - ListRowPreviewTrack("Odoriko", "Vaundy · strobo", "3:37"), - ListRowPreviewTrack("Instant Crush", "Daft Punk · Random Access Memories", "5:37"), -) - -@BaseUIPhonePreview -@Composable -private fun ListRowTilePreview() { - BaseUIPreview { - var selectedIndex by remember { mutableStateOf(0) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - ListRowPreviewTracks.forEachIndexed { index, track -> - ListRowTile( - selected = selectedIndex == index, - onClick = { selectedIndex = index }, - leading = { ListRowTilePreviewArtwork() }, - title = { - Text( - text = track.title, - style = MaterialTheme.typography.bodyMedium, - ) - }, - subtitle = { - Text( - text = track.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailing = { - Text( - text = track.duration, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } - - ListRowTile( - enabled = false, - title = { - Text( - text = "Add to queue", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), - ) - }, - subtitle = { - Text( - text = "Unavailable offline", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), - ) - }, - ) - } - } -} - -@Composable -private fun ListRowTilePreviewArtwork() { - Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Iconsax.IconsaxMusic, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt deleted file mode 100644 index 0a159766..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -private val RadioSize = 22.dp -private val RadioDotSize = 10.dp - -private data class ResolvedRadioState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveRadioState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedRadioState { - return when { - isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) - isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered) - else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal) - } -} - -@Composable -fun Radio( - selected: Boolean, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.CheckBoxTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.checkBox - val radioTheme = theme ?: baseTheme - val source = remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val style = if (selected) radioTheme.selected else radioTheme.unselected - val resolved = resolveRadioState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp - - val dotProgress by animateFloatAsState( - targetValue = if (selected) 1f else 0f, - animationSpec = tween(durationMillis = 180), - label = "radioDot", - ) - - Box( - modifier = modifier - .size(RadioSize) - .graphicsLayer { - translationY = lift.toPx() - shape = CircleShape - clip = true - } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = CircleShape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(CircleShape) - .background(resolved.colors.background, CircleShape) - .border(BorderStroke(0.5.dp, resolved.border.color), CircleShape) - .highlight(resolved.colors.highlight) - .then( - if (onClick != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - interactionSource = source, - indication = ripple(bounded = true, radius = RadioSize / 2), - enabled = enabled, - onClick = onClick, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .size(RadioDotSize * dotProgress) - .clip(CircleShape) - .background(radioTheme.checkmarkColor, CircleShape), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun RadioPreview() { - BaseUIPreview { - val options = listOf("Automatic", "High", "Lossless", "Data saver") - var selected by remember { mutableStateOf(0) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - options.forEachIndexed { index, label -> - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio( - selected = selected == index, - onClick = { selected = index }, - ) - Text(label, style = MaterialTheme.typography.bodyMedium) - } - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = true, onClick = {}, enabled = false) - Text("Disabled", style = MaterialTheme.typography.bodyMedium) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt deleted file mode 100644 index 70ffe90f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -/** - * A theme-aware replacement for [Scaffold]. - * - * A scaffold is always the current theme's **Primary Background** — there is no option to - * switch its surface. Material's [Scaffold] would paint Material's own surface colour and - * hide the selected [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass), so [BaseScaffold] - * paints the theme's primary background material instead. - * - * [containerColor] is a narrow escape hatch for the rare screen that paints its own - * backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a - * surface-style switch. - * - * It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host, - * floating action button) and hands the resolved [PaddingValues] to [content]. - */ -@Composable -fun BaseScaffold( - modifier: Modifier = Modifier, - topBar: @Composable () -> Unit = {}, - bottomBar: @Composable () -> Unit = {}, - snackbarHost: @Composable () -> Unit = {}, - floatingActionButton: @Composable () -> Unit = {}, - containerColor: Color? = null, - contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, - content: @Composable (PaddingValues) -> Unit, -) { - Scaffold( - modifier = modifier, - topBar = topBar, - bottomBar = bottomBar, - snackbarHost = snackbarHost, - floatingActionButton = floatingActionButton, - containerColor = Color.Transparent, - contentWindowInsets = contentWindowInsets, - ) { innerPadding -> - val colors = LocalBaseUIColors.current - val backgroundModifier = containerColor?.let { Modifier.background(it) } - ?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight) - Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) { - content(innerPadding) - } - } -} - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun BaseScaffoldPreview() { - BaseUIPreview { - BaseScaffold( - topBar = { - Text( - text = "Title", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) - }, - bottomBar = { - Text( - text = "Bottom bar", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Content") - Text("Sits on the selected surface theme.", style = MaterialTheme.typography.bodySmall) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt deleted file mode 100644 index 2d059068..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt +++ /dev/null @@ -1,369 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import kotlin.math.roundToInt - -private val SliderTrackHeight = 7.dp -private val SliderThumbSize = 24.dp -private val SliderMinHeight = 32.dp - -@Composable -fun Slider( - value: Float, - onValueChange: (Float) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - valueRange: ClosedFloatingPointRange = 0f..1f, - steps: Int = 0, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - onValueChangeFinished: () -> Unit = {}, - theme: BaseUITheme.SliderTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.slider - val sliderTheme = theme ?: baseTheme - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - val density = LocalDensity.current - var sliderWidth by remember { mutableIntStateOf(0) } - - val range = valueRange.endInclusive - valueRange.start - val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f - - val stateColor = when { - isPressed -> sliderTheme.trackActiveColor.pressed - isHovered -> sliderTheme.trackActiveColor.hovered - else -> sliderTheme.trackActiveColor.normal - } - val inactiveColor = when { - isPressed -> sliderTheme.trackInactiveColor.pressed - isHovered -> sliderTheme.trackInactiveColor.hovered - else -> sliderTheme.trackInactiveColor.normal - } - val thumbColor = when { - isPressed -> sliderTheme.thumbColor.pressed - isHovered -> sliderTheme.thumbColor.hovered - else -> sliderTheme.thumbColor.normal - } - val thumbShadowState = when { - isPressed -> sliderTheme.thumbShadow.pressed - isHovered -> sliderTheme.thumbShadow.hovered - else -> sliderTheme.thumbShadow.normal - } - - val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f) - val trackInactiveColor = if (enabled) inactiveColor else inactiveColor.copy(alpha = 0.38f) - val resolvedThumbColor = if (enabled) thumbColor else thumbColor.copy(alpha = 0.38f) - - val thumbScale by animateFloatAsState( - targetValue = when { - isPressed -> 1.15f - isHovered -> 1.08f - else -> 1f - }, - animationSpec = tween(durationMillis = 150), - label = "thumbScale", - ) - - Box( - modifier = modifier - .fillMaxWidth() - .heightIn(min = SliderMinHeight) - .onSizeChanged { sliderWidth = it.width } - .hoverable(interactionSource = interactionSource, enabled = enabled) - .pointerInput(enabled, valueRange, steps) { - if (!enabled) return@pointerInput - - val stepSize = if (steps > 0) range / (steps + 1) else 0f - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - - fun valueFromX(x: Float): Float { - val width = size.width.toFloat() - val trackWidth = width - 2 * thumbHalfPx - if (trackWidth <= 0f) return valueRange.start - val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start - val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) - return if (steps > 0) { - val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() - (valueRange.start + stepIndex * stepSize).coerceIn( - valueRange.start, - valueRange.endInclusive, - ) - } else { - coerced - } - } - - detectTapGestures { offset -> - onValueChange(valueFromX(offset.x)) - onValueChangeFinished() - } - } - .pointerInput(enabled, valueRange, steps) { - if (!enabled) return@pointerInput - - val stepSize = if (steps > 0) range / (steps + 1) else 0f - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - - fun valueFromX(x: Float): Float { - val width = size.width.toFloat() - val trackWidth = width - 2 * thumbHalfPx - if (trackWidth <= 0f) return valueRange.start - val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start - val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) - return if (steps > 0) { - val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() - (valueRange.start + stepIndex * stepSize).coerceIn( - valueRange.start, - valueRange.endInclusive, - ) - } else { - coerced - } - } - - detectHorizontalDragGestures( - onDragEnd = { onValueChangeFinished() }, - ) { change, _ -> - change.consume() - onValueChange(valueFromX(change.position.x)) - } - }, - contentAlignment = Alignment.CenterStart, - ) { - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(SliderTrackHeight) - .align(Alignment.CenterStart), - ) { - val thumbHalfPx = SliderThumbSize.toPx() / 2f - val trackY = size.height / 2f - val trackStart = thumbHalfPx - val trackEnd = size.width - thumbHalfPx - val trackWidth = trackEnd - trackStart - - if (trackWidth > 0f) { - val trackRadius = CornerRadius(size.height / 2f) - - drawRoundRect( - brush = Brush.verticalGradient( - colors = listOf( - trackInactiveColor.copy(alpha = 0.45f), - trackInactiveColor, - trackInactiveColor.copy(alpha = 0.7f), - ), - ), - topLeft = Offset(trackStart, 0f), - size = Size(trackWidth, size.height), - cornerRadius = trackRadius, - ) - drawLine( - color = Color.White.copy(alpha = 0.15f), - start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), - end = Offset(trackEnd - size.height / 2f, 1.dp.toPx()), - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - ) - - val activeEnd = trackStart + trackWidth * fraction - drawRoundRect( - brush = Brush.verticalGradient( - colors = listOf( - trackActiveColor.copy(alpha = 0.75f), - trackActiveColor, - trackActiveColor.copy(alpha = 0.9f), - ), - ), - topLeft = Offset(trackStart, 0f), - size = Size((activeEnd - trackStart).coerceAtLeast(0f), size.height), - cornerRadius = trackRadius, - ) - drawLine( - color = Color.White.copy(alpha = 0.22f), - start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), - end = Offset(activeEnd - size.height / 2f, 1.dp.toPx()), - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - ) - - if (steps > 0 && enabled) { - val stepSpacing = trackWidth / (steps + 1) - for (i in 1 downTo steps) { - val x = trackStart + stepSpacing * i - drawCircle( - color = if (x <= activeEnd) { - sliderTheme.stepActiveColor - } else { - sliderTheme.stepInactiveColor - }, - radius = 2.dp.toPx(), - center = Offset(x, trackY), - ) - } - } - } - } - - Box( - modifier = Modifier - .size(SliderThumbSize) - .offset { - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - val trackWidth = sliderWidth.toFloat() - 2 * thumbHalfPx - val x = thumbHalfPx + trackWidth * fraction - thumbHalfPx - IntOffset(x.roundToInt(), 0) - } - .graphicsLayer { - scaleX = thumbScale - scaleY = thumbScale - } - .shadow( - elevation = thumbShadowState.elevation, - shape = CircleShape, - ambientColor = thumbShadowState.ambientColor, - spotColor = thumbShadowState.spotColor, - ) - .background( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.6f), - resolvedThumbColor.copy(alpha = 0.95f), - resolvedThumbColor, - ), - center = Offset(0f, -0.55f), - radius = 1.1f, - ), - shape = CircleShape, - ) - .border( - width = 0.8.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.08f), - ), - ), - shape = CircleShape, - ), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun SliderPreview() { - BaseUIPreview { - var volume by remember { mutableFloatStateOf(0.65f) } - var progress by remember { mutableFloatStateOf(0.3f) } - var quality by remember { mutableFloatStateOf(2f) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(28.dp), - ) { - SliderPreviewField(title = "Volume", value = "${(volume * 100).toInt()}%") { - Slider(value = volume, onValueChange = { volume = it }) - } - SliderPreviewField(title = "Progress", value = "${(progress * 100).toInt()}%") { - Slider(value = progress, onValueChange = { progress = it }) - } - SliderPreviewField(title = "Quality", value = "${quality.toInt()} / 4") { - Slider( - value = quality, - onValueChange = { quality = it }, - valueRange = 0f..4f, - steps = 3, - ) - } - SliderPreviewField(title = "Disabled", value = "40%") { - Slider(value = 0.4f, onValueChange = {}, enabled = false) - } - } - } -} - -@Composable -private fun SliderPreviewField( - title: String, - value: String, - content: @Composable () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(title, style = MaterialTheme.typography.bodyMedium) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - content() - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt deleted file mode 100644 index d7c7b366..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt +++ /dev/null @@ -1,699 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlin.random.Random -import kotlinx.serialization.Serializable - -/** - * The Frutiger-Aero material families available to [BaseSurface]. - * - * - [Watery]: deep aqua water with caustic light and bubbles. - * - [Icy]: pale frosted ice with crystalline facets and frost. - * - [Chrome]: brushed metal with a hard specular reflection. - * - [Acrylic]: translucent tinted panel with fine grain. - * - [Glass]: clear, glossy glass with a strong sheen. - * - * Each style doubles as a full app theme (see [BaseUIColorScheme]). - */ -@Serializable -enum class SurfaceStyle { - Watery, - Icy, - Chrome, - Acrylic, - Glass, -} - -/** - * The role a surface plays within a theme. Each style produces a distinct material for - * every role, so a theme is composed from them: - * - * - [PrimaryBackground] — the subtle main background (pages, panels). - * - [SecondaryBackground] — a distinct, complementary background hue that pairs with the - * primary (grouped/alternate sections). - * - [PrimaryAccent] — the primary accent surface for interactive elements (buttons, - * toggles, active chips). - * - [SecondaryAccent] — a complementary accent surface (secondary actions, highlights). - * - [InverseBackground] — the inverted-brightness background (tooltips, snackbars). - * - [InverseAction] — the inverted-brightness action surface. - */ -@Serializable -enum class SurfaceRole { - PrimaryBackground, - SecondaryBackground, - PrimaryAccent, - SecondaryAccent, - InverseBackground, - InverseAction, -} - -/** - * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials, - * in the given [SurfaceRole]. - * - * Distinct from [androidx.compose.material3.Surface] — this draws decorative material - * backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching - * [LocalContentColor] so text/content inside stays legible. - */ -@Composable -fun BaseSurface( - modifier: Modifier = Modifier, - style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, - role: SurfaceRole = SurfaceRole.PrimaryBackground, - shape: Shape = RectangleShape, - elevation: Dp? = null, - contentColor: Color? = null, - contentPadding: PaddingValues = PaddingValues.Zero, - contentAlignment: Alignment = Alignment.TopStart, - content: @Composable BoxScope.() -> Unit, -) { - val ambientScheme = LocalBaseUIColors.current - val isLight = ambientScheme.isLight - // A surface is rendered in its own style's colour scheme, so previewing a style shows - // that style's real accent instead of the ambient theme's. When the requested style is - // the ambient theme, the ambient scheme is used so a user-chosen accent still applies. - val ownScheme = remember(style, isLight) { - baseUIColorScheme(style, isLight, style.accentColor(isLight)) - } - val scheme = if (ambientScheme.surfaceTheme == style) ambientScheme else ownScheme - val palette = surfacePalette(style, isLight) - - val baseElevation = when (style) { - SurfaceStyle.Watery -> 14.dp - SurfaceStyle.Icy -> 12.dp - SurfaceStyle.Chrome -> 10.dp - SurfaceStyle.Acrylic -> 16.dp - SurfaceStyle.Glass -> 12.dp - } - val resolvedElevation = elevation ?: when (role) { - SurfaceRole.PrimaryBackground -> baseElevation - SurfaceRole.SecondaryBackground -> baseElevation + 2.dp - SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp - SurfaceRole.InverseBackground -> baseElevation - SurfaceRole.InverseAction -> baseElevation + 6.dp - } - val resolvedContentColor = contentColor ?: when (role) { - SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary - SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary - SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary - SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary - SurfaceRole.InverseBackground -> scheme.onBackgroundInverse - SurfaceRole.InverseAction -> scheme.onActionInverse - } - val ambient = when (role) { - SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f) - SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f) - SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f) - SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f) - else -> palette.onSurface.copy(alpha = 0.2f) - } - val spot = ambient.copy(alpha = (ambient.alpha + 0.07f).coerceAtMost(1f)) - val borderBrush = roleBorderBrush(style, role, isLight, scheme) - - // Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn - // behind the node and, because the panel is see-through, shows up as a dark inset - // shape. Accent and secondary/inverse roles are filled, so they can cast one. - val translucent = role == SurfaceRole.PrimaryBackground && - (style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic) - - CompositionLocalProvider(LocalContentColor provides resolvedContentColor) { - Box( - modifier = modifier - .then( - if (!translucent) { - Modifier.shadow( - elevation = resolvedElevation, - shape = shape, - ambientColor = ambient, - spotColor = spot, - ) - } else { - Modifier - } - ) - .clip(shape) - .drawWithCache { - onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) } - } - .border(BorderStroke(1.dp, borderBrush), shape) - .padding(contentPadding), - contentAlignment = contentAlignment, - content = content, - ) - } -} - -/** - * Paints the full-bleed material of [style] as a page background: the primary material - * body plus its sheen and reflections, but no panel bevel (a backdrop has no edges). - */ -fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier = - drawWithCache { - onDrawBehind { - drawStyleBase(style, isLight) - drawStyleDecorations(style, isLight) - } - } - -/** - * Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band, - * grain) and its inner bevel on a node that already carries a base fill. Compact components - * — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role] - * without redrawing the fill (which comes from their own interaction-state brush). - */ -fun Modifier.baseSurfaceTexture( - style: SurfaceStyle, - role: SurfaceRole, - isLight: Boolean, -): Modifier = drawWithCache { - onDrawBehind { - val effectiveLight = when (role) { - SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight - else -> isLight - } - drawStyleDecorations(style, effectiveLight) - drawInnerBevel(effectiveLight) - } -} - -/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */ -@Composable -fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier { - val scheme = LocalBaseUIColors.current - return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight) -} - -/** - * Paints the full [SurfaceStyle] material for [role] — base fill, material character and inner - * bevel — using the ambient [BaseUIColorScheme]. Use it to make a chrome panel (a sidebar, a - * player bar) a first-class surface rather than a flat Material surface. - */ -@Composable -fun Modifier.baseSurfaceMaterial( - role: SurfaceRole = SurfaceRole.PrimaryBackground, - style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, -): Modifier { - val scheme = LocalBaseUIColors.current - return drawWithCache { - onDrawBehind { drawSurfaceMaterial(style, role, scheme.isLight, scheme) } - } -} - -private fun DrawScope.drawSurfaceMaterial( - style: SurfaceStyle, - role: SurfaceRole, - isLight: Boolean, - scheme: BaseUIColorScheme, -) { - // Role base fill. - when (role) { - SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight) - - // A distinct, complementary background hue (keeps the style's texture below). - SurfaceRole.SecondaryBackground -> drawRect(color = scheme.backgroundSecondary) - - SurfaceRole.PrimaryAccent -> drawAccentBase(scheme.accentPrimary) - SurfaceRole.SecondaryAccent -> drawAccentBase(scheme.accentSecondary) - - // Opaque inverted background so translucent styles still read as a solid surface. - SurfaceRole.InverseBackground -> drawRect(color = scheme.backgroundInverse) - - SurfaceRole.InverseAction -> drawAccentBase(scheme.actionInverse) - } - - // The material's own character (bubbles, facets, grain, specular band, sheen). - val effectiveLight = when (role) { - SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight - else -> isLight - } - drawStyleDecorations(style, effectiveLight) - drawInnerBevel(effectiveLight) -} - -// --- Material base gradients (one per style) --------------------------------- - -private fun DrawScope.drawStyleBase(style: SurfaceStyle, isLight: Boolean) { - when (style) { - SurfaceStyle.Glass -> { - val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.4f else 0.2f), - tint.copy(alpha = if (isLight) 0.24f else 0.1f), - ), - ), - ) - } - - SurfaceStyle.Acrylic -> { - val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.45f else 0.22f), - tint.copy(alpha = if (isLight) 0.28f else 0.12f), - ), - ), - ) - } - - SurfaceStyle.Chrome -> { - if (isLight) { - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFFF8FBFF), - 0.16f to Color(0xFFD6DEE7), - 0.4f to Color(0xFF8E99A8), - 0.5f to Color(0xFFEDF2F7), - 0.62f to Color(0xFF9AA5B4), - 1f to Color(0xFF6C7787), - ), - ) - } else { - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFF3B424C), - 0.16f to Color(0xFF2A303A), - 0.4f to Color(0xFF1B2028), - 0.5f to Color(0xFF343B45), - 0.62f to Color(0xFF232830), - 1f to Color(0xFF141820), - ), - ) - } - } - - SurfaceStyle.Icy -> { - val colors = if (isLight) { - listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0)) - } else { - listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26)) - } - drawRect(brush = Brush.verticalGradient(colors)) - } - - SurfaceStyle.Watery -> { - // A clear-but-soft aqua column: unmistakably water, without a heavy flat blue. - val colors = if (isLight) { - listOf(Color(0xFFD8F0FD), Color(0xFFB0DEF7), Color(0xFF83C8EE)) - } else { - listOf(Color(0xFF123249), Color(0xFF0B2135), Color(0xFF06141F)) - } - drawRect(brush = Brush.verticalGradient(colors)) - } - } -} - -/** The accent-filled base used by [SurfaceRole.Action]. */ -private fun DrawScope.drawAccentBase(accent: Color) { - drawRect( - brush = Brush.verticalGradient( - listOf( - lerp(accent, Color.White, 0.45f), - accent, - lerp(accent, Color.Black, 0.16f), - ), - ), - ) -} - -// --- Material decorations (one per style) ------------------------------------ - -private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) { - when (style) { - SurfaceStyle.Glass -> { - drawGloss(if (isLight) 0.55f else 0.16f) - drawBottomReflection(if (isLight) 0.4f else 0.1f) - drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) - } - - SurfaceStyle.Acrylic -> { - // Fine grain so the panel reads as a translucent acrylic sheet. - val random = Random(23) - repeat(180) { - val light = random.nextBoolean() - drawCircle( - color = if (light) { - Color.White.copy(alpha = if (isLight) 0.16f else 0.06f) - } else { - Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f) - }, - radius = 0.7f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) - } - drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) - } - - SurfaceStyle.Chrome -> { - // Hard specular reflection near the top. - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = if (isLight) 0.85f else 0.18f), - Color.Transparent, - ), - startY = size.height * 0.05f, - endY = size.height * 0.22f, - ), - ) - // Darkened lower edge for the metal bevel. - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)), - startY = size.height * 0.78f, - endY = size.height, - ), - ) - } - - SurfaceStyle.Icy -> { - val frost = if (isLight) Color.White else Color(0xFFB8CCE0) - - // Crystalline facets (kept inset so they are never clipped by the shape). - val random = Random(37) - repeat(4) { - val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f) - val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f) - val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f) - val path = Path().apply { - moveTo(x, y) - lineTo(x + s, y + s * 0.4f) - lineTo(x + s * 0.4f, y + s) - close() - } - drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f)) - } - - // Frost speckles. - repeat(90) { - drawCircle( - color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), - radius = 0.8f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) - } - drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f) - } - - SurfaceStyle.Watery -> { - val sheen = if (isLight) Color.White else Color(0xFFAFD3F2) - - // Caustic light pooling from the surface. - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - sheen.copy(alpha = if (isLight) 0.6f else 0.2f), - Color.Transparent, - ), - startY = 0f, - endY = size.height * 0.55f, - ), - ) - - val random = Random(53) - // Refracted caustic blobs — the bright, rippling patterns of moving water. - repeat(9) { - val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f) - val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) - val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f) - drawOval( - brush = Brush.radialGradient( - colors = listOf( - sheen.copy(alpha = if (isLight) 0.5f else 0.22f), - Color.Transparent, - ), - center = Offset(cx, cy), - radius = rx, - ), - topLeft = Offset(cx - rx, cy - rx * 0.7f), - size = Size(rx * 2f, rx * 1.4f), - ) - } - - // Ripple crests: thin horizontal highlights that read as surface waves. - repeat(3) { - val y = size.height * (0.18f + random.nextFloat() * 0.64f) - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - sheen.copy(alpha = if (isLight) 0.4f else 0.16f), - Color.Transparent, - ), - startY = y - size.height * 0.035f, - endY = y + size.height * 0.035f, - ), - ) - } - - drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true) - } - } -} - -private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = alpha), Color.Transparent), - startY = 0f, - endY = size.height * heightFraction, - ), - ) -} - -private fun DrawScope.drawBottomReflection(alpha: Float) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.White.copy(alpha = alpha)), - startY = size.height * 0.7f, - endY = size.height, - ), - ) -} - -/** A bright inner top edge plus a shaded inner bottom edge, giving the pane a glass bevel. */ -private fun DrawScope.drawInnerBevel(isLight: Boolean) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), Color.Transparent), - startY = 0f, - endY = size.height * 0.1f, - ), - ) - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = if (isLight) 0.08f else 0.28f)), - startY = size.height * 0.9f, - endY = size.height, - ), - ) -} - -private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) { - val random = Random(seed) - repeat(count) { - val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f) - // Keep bubbles inset so they are never clipped into hard arcs at the edges. - val cx = radius + random.nextFloat() * (size.width - 2 * radius).coerceAtLeast(0f) - val cy = radius + random.nextFloat() * (size.height - 2 * radius).coerceAtLeast(0f) - val center = Offset(cx, cy) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color.White.copy(alpha = alpha), Color.White.copy(alpha = 0f)), - center = center, - radius = radius, - ), - radius = radius, - center = center, - ) - if (rim) { - drawCircle( - color = Color.White.copy(alpha = alpha * 0.7f), - radius = radius, - center = center, - style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)), - ) - } - } -} - -private fun styleBorderBrush(style: SurfaceStyle): Brush = when (style) { - SurfaceStyle.Watery -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Icy -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)), - ) - - SurfaceStyle.Chrome -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)), - ) - - SurfaceStyle.Acrylic -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Glass -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)), - ) -} - -private fun roleBorderBrush( - style: SurfaceStyle, - role: SurfaceRole, - isLight: Boolean, - scheme: BaseUIColorScheme, -): Brush = when (role) { - SurfaceRole.PrimaryBackground -> styleBorderBrush(style) - - SurfaceRole.SecondaryBackground -> Brush.verticalGradient( - listOf( - lerp(scheme.backgroundSecondary, Color.White, 0.5f) - .copy(alpha = if (isLight) 0.85f else 0.6f), - Color.White.copy(alpha = 0.12f), - ), - ) - - SurfaceRole.PrimaryAccent -> accentEdgeBrush(scheme.accentPrimary) - SurfaceRole.SecondaryAccent -> accentEdgeBrush(scheme.accentSecondary) - - SurfaceRole.InverseBackground -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.1f)), - ) - - SurfaceRole.InverseAction -> accentEdgeBrush(scheme.actionInverse) -} - -private fun accentEdgeBrush(accent: Color): Brush = Brush.verticalGradient( - listOf(lerp(accent, Color.White, 0.6f), lerp(accent, Color.Black, 0.2f)), -) - -/** - * One preview showing everything about surfaces: every [SurfaceStyle] crossed with every - * [SurfaceRole], plus the full-bleed backdrops — no need to scroll through many previews. - */ -@BaseUIDesktopPreview -@Composable -private fun SurfacePreview() { - BaseUIPreview { - val colors = LocalBaseUIColors.current - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - SurfaceStyle.entries.forEach { style -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - SurfaceRole.entries.forEach { role -> - BaseSurface( - style = style, - role = role, - modifier = Modifier - .weight(1f) - .height(84.dp), - contentPadding = PaddingValues(10.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = style.name, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = role.name, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - } - - // Full-bleed backdrops (the page background material, without the panel bevel). - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - SurfaceStyle.entries.forEach { style -> - Box( - modifier = Modifier - .weight(1f) - .height(60.dp) - .clip(RoundedCornerShape(12.dp)) - .baseSurfaceBackdrop(style, colors.isLight), - contentAlignment = Alignment.Center, - ) { - Text( - text = style.name, - style = MaterialTheme.typography.labelSmall, - color = colors.onSurface, - ) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt deleted file mode 100644 index 0309f2a4..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt +++ /dev/null @@ -1,291 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.User - -private val TextFieldMinHeight = 44.dp - -private data class ResolvedTextFieldState( - val background: Brush, - val highlight: Color, - val shape: Shape, - val border: BaseUITheme.Border, - val shadow: BaseUITheme.Shadow, - val foreground: Color, -) - -@Composable -private fun resolveTextFieldState( - theme: BaseUITheme.TextFieldTheme, - isFocused: Boolean, - isHovered: Boolean, -): ResolvedTextFieldState { - return when { - isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused) - isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered) - else -> ResolvedTextFieldState(theme.background.normal, theme.highlight.normal, theme.shape.normal, theme.border.normal, theme.shadow.normal, theme.foreground.normal) - } -} - -@Composable -fun TextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - label: @Composable (() -> Unit)? = null, - singleLine: Boolean = false, - maxLines: Int = Int.MAX_VALUE, - enabled: Boolean = true, - readOnly: Boolean = false, - isError: Boolean = false, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - visualTransformation: VisualTransformation = VisualTransformation.None, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - textStyle: TextStyle = TextStyle.Default, - cursorBrush: Color? = null, - theme: BaseUITheme.TextFieldTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.textField - val textFieldTheme = theme ?: baseTheme - val isFocused by interactionSource.collectIsFocusedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - - val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered) - - val resolvedBorder = if (isError) { - BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width) - } else { - state.border - } - - val contentColor = when { - !enabled -> state.foreground.copy(alpha = 0.38f) - else -> state.foreground - } - - val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor - - Column(modifier = modifier) { - AnimatedVisibility( - visible = label != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - label?.invoke() - } - - Box( - modifier = Modifier - .defaultMinSize(minHeight = TextFieldMinHeight) - .then( - if (state.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = state.shadow.elevation, - shape = state.shape, - ambientColor = state.shadow.ambientColor, - spotColor = state.shadow.spotColor, - ) - } else Modifier - ) - .clip(state.shape) - .background(state.background, state.shape) - .border(BorderStroke(resolvedBorder.width, resolvedBorder.color), state.shape) - .highlight(state.highlight) - .padding(textFieldTheme.padding), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (leadingIcon != null) { - leadingIcon() - } - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .hoverable(interactionSource = interactionSource, enabled = enabled), - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle.copy(color = contentColor), - cursorBrush = cursor, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - visualTransformation = visualTransformation, - interactionSource = interactionSource, - decorationBox = { innerTextField -> - if (value.isEmpty() && placeholder != null && !isFocused) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Box(contentAlignment = Alignment.CenterStart) { - placeholder() - } - } - } else { - innerTextField() - } - }, - ) - } - - if (trailingIcon != null) { - trailingIcon() - } - } - } - } -} - -@BaseUIPhonePreview -@Composable -private fun TextFieldPreview() { - BaseUIPreview { - var email by remember { mutableStateOf("") } - var query by remember { mutableStateOf("Twenty One Pilots") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - TextField( - value = email, - onValueChange = { email = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("you@example.com") }, - label = { TextFieldPreviewLabel("Email address") }, - leadingIcon = { - Icon( - imageVector = Iconsax.User, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - ) - - TextField( - value = query, - onValueChange = { query = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search songs, artists, albums...") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - ) - }, - trailingIcon = { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Clear", - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - ) - - TextField( - value = "not-an-email", - onValueChange = {}, - modifier = Modifier.fillMaxWidth(), - label = { TextFieldPreviewLabel("Error state") }, - isError = true, - singleLine = true, - ) - - TextField( - value = "Read only value", - onValueChange = {}, - modifier = Modifier.fillMaxWidth(), - label = { TextFieldPreviewLabel("Disabled") }, - enabled = false, - singleLine = true, - ) - } - } -} - -@Composable -private fun TextFieldPreviewLabel(text: String) { - Text( - text = text, - modifier = Modifier.padding(bottom = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt deleted file mode 100644 index 33880cab..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ /dev/null @@ -1,962 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.AutoCompleteMenuTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.CheckBoxTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.ChipTabTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.SliderTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.TextFieldTheme - -@Stable -data class BaseUITheme( - val colors: BaseUIColorScheme, - val surfaceTheme: SurfaceStyle, - val buttons: ButtonVariants, - val iconButtons: ButtonVariants, - val textField: TextFieldTheme, - val autoCompleteMenuTheme: AutoCompleteMenuTheme, - val slider: SliderTheme, - val checkBox: CheckBoxTheme, - val chipTab: ChipTabTheme, - val toggle: ToggleTheme, - val card: CardTheme, - val listRowTile: ListRowTheme, - val dialog: DialogTheme, - val dropdownMenu: DropdownMenuTheme, -) { - - @Stable - data class InteractionState( - val normal: T, - val hovered: T, - val pressed: T, - val focused: T, - ) { - companion object { - fun fromSingleValue(value: T): InteractionState { - return InteractionState(value, value, value, value) - } - } - } - - @Stable - data class AdvancedInteractionState( - val normal: T, - val hovered: T, - val pressed: T, - val focused: T, - val selected: T, - val disabled: T, - ) { - companion object { - fun fromSingleValue(value: T): AdvancedInteractionState { - return AdvancedInteractionState(value, value, value, value, value, value) - } - } - } - - @Stable - data class Border( - val color: Color, - val width: Dp, - ) - - @Stable - data class Shadow( - val elevation: Dp, - val clip: Boolean = elevation > 0.dp, - val ambientColor: Color, - val spotColor: Color, - ) - - @Stable - data class ButtonVariants( - val primary: ButtonStyle, - val secondary: ButtonStyle, - val outline: ButtonStyle, - val ghost: ButtonStyle, - ) - - @Stable - data class ButtonStyle( - val colors: InteractionState, - val shape: InteractionState, - val shadow: InteractionState, - val border: InteractionState, - val padding: InteractionState, - ) - - @Stable - data class ButtonColors( - val background: Brush, - val foreground: Color, - val highlight: Color, - /** - * The [SurfaceRole] this colour represents. When set, components overlay the - * matching [SurfaceStyle] material (gloss, bubbles, facets, specular band, grain) - * on top of [background], so buttons and other actionable items share the exact - * look of a [BaseSurface]. `null` means no material (e.g. ghost buttons). - */ - val surfaceRole: SurfaceRole? = null, - ) - - data class CardTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val padding: PaddingValues, - val shadow: Shadow, - /** The [SurfaceRole] the card renders, so it can overlay the matching material. */ - val surfaceRole: SurfaceRole? = null, - ) - - data class ListRowTheme( - val background: AdvancedInteractionState, - val shape: AdvancedInteractionState, - val border: AdvancedInteractionState, - val shadow: AdvancedInteractionState, - val padding: PaddingValues, - val textStyle: AdvancedInteractionState, - val foreground: AdvancedInteractionState, - ) - - data class TextFieldTheme( - val background: InteractionState, - val highlight: InteractionState, - val shape: InteractionState, - val border: InteractionState, - val shadow: InteractionState, - val padding: PaddingValues, - val textStyle: TextStyle, - val cursor: Brush, - val foreground: InteractionState, - ) - - data class AutoCompleteMenuTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val padding: PaddingValues, - val shadowElevation: Dp, - ) - - data class DialogTheme( - val background: Brush, - val scrim: Color, - val shape: Shape, - val border: Border, - val shadow: Shadow, - val padding: PaddingValues, - ) - - data class DropdownMenuTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val shadow: Shadow, - val itemHoverBackground: Brush, - val itemForeground: Color, - val itemHighlight: Color, - val padding: PaddingValues, - val itemPadding: PaddingValues, - ) - - data class SliderTheme( - val trackActiveColor: InteractionState, - val trackInactiveColor: InteractionState, - val thumbColor: InteractionState, - val thumbShadow: InteractionState, - val stepActiveColor: Color, - val stepInactiveColor: Color, - ) - - data class CheckBoxTheme( - val selected: ButtonStyle, - val unselected: ButtonStyle, - val checkmarkColor: Color, - ) - - data class ChipTabTheme( - val selected: ButtonStyle, - val unselected: ButtonStyle, - ) - - data class ToggleTheme( - val checked: ButtonStyle, - val unchecked: ButtonStyle, - val thumb: InteractionState, - val thumbShadow: InteractionState, - ) -} - -val LocalBaseUITheme = staticCompositionLocalOf { - throw IllegalStateException("No BaseUITheme provided") -} - -@Composable -fun rememberBaseUITheme( - surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, - accentColor: Color? = null, -): BaseUITheme { - val colors = rememberBaseUIColorScheme(surfaceTheme, accentColor) - val isLight = colors.isLight - - // ------------------------------------------------------------------ - // Frutiger-Aero glass palette. - // - // Surfaces are glossy, top-lit gradients with a strong white specular - // highlight and translucent "glass" edges, lifted by soft, coloured - // shadows — the aqua/glass language of Frutiger-Aero. - // ------------------------------------------------------------------ - // Primary surface (subtle) with the style's palette, and the secondary surface - // (slightly accented) used by cards and grouped content. - val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f) - val panelBottom = colors.surfaceVariant - val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f) - val secondaryBottom = colors.backgroundSecondary - val glassPressed = if (isLight) { - lerp(colors.surfaceContainerHighest, Color.White, 0.06f) - } else { - lerp(colors.surfaceContainerHighest, Color.Black, 0.08f) - } - - val gloss = if (isLight) Color.White.copy(alpha = 0.75f) else Color.White.copy(alpha = 0.18f) - val glossSoft = if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.12f) - val glassEdge = if (isLight) Color.White.copy(alpha = 0.95f) else Color.White.copy(alpha = 0.22f) - val glassBorder = if (isLight) { - lerp(colors.outlineVariant, Color.White, 0.55f) - } else { - colors.outlineVariant.copy(alpha = 0.5f) - } - val shadowColor = colors.onSurface - - fun glassBrush(top: Color, bottom: Color): Brush = - Brush.verticalGradient(listOf(top, bottom)) - - fun aqua(accent: Color): Brush = Brush.verticalGradient( - listOf( - lerp(accent, Color.White, 0.45f), - accent, - lerp(accent, Color.Black, 0.14f), - ) - ) - - val defaultShape = RoundedCornerShape(16.dp) - val defaultShapeState = - BaseUITheme.InteractionState(defaultShape, defaultShape, defaultShape, defaultShape) - val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp) - val noBorderState = BaseUITheme.InteractionState(noBorder, noBorder, noBorder, noBorder) - val noShadow = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent) - val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow, noShadow) - val defaultButtonPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - ) - val iconButtonPadding = BaseUITheme.InteractionState( - PaddingValues(9.dp), - PaddingValues(9.dp), - PaddingValues(9.dp), - PaddingValues(9.dp), - ) - - // --- Glass (outline) ------------------------------------------------- - val outlineColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = glassBrush(panelTop, panelBottom), - foreground = colors.onSurface, - highlight = gloss, - ), - hovered = BaseUITheme.ButtonColors( - background = glassBrush(panelTop, panelBottom), - foreground = colors.onSurface, - highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f), - ), - pressed = BaseUITheme.ButtonColors( - background = glassBrush(glassPressed, glassPressed), - foreground = colors.onSurface, - highlight = glossSoft, - ), - focused = BaseUITheme.ButtonColors( - background = glassBrush(panelTop, panelBottom), - foreground = colors.onSurface, - highlight = gloss, - ), - ).withRole(SurfaceRole.PrimaryBackground) - val glassShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 8.dp, - true, - shadowColor.copy(alpha = 0.16f), - shadowColor.copy(alpha = 0.2f), - ), - hovered = BaseUITheme.Shadow( - 14.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f), - ), - pressed = BaseUITheme.Shadow( - 2.dp, - true, - shadowColor.copy(alpha = 0.1f), - shadowColor.copy(alpha = 0.12f), - ), - focused = BaseUITheme.Shadow( - 8.dp, - true, - shadowColor.copy(alpha = 0.16f), - shadowColor.copy(alpha = 0.2f), - ), - ) - val glassBorderState = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(glassBorder, 1.dp), - hovered = BaseUITheme.Border(glassEdge, 1.dp), - pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp), - focused = BaseUITheme.Border(glassEdge, 1.dp), - ) - - // --- Aqua accent (primary) ------------------------------------------- - val primaryEdge = lerp(colors.primary, Color.White, 0.5f) - val primaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), - hovered = BaseUITheme.ButtonColors( - aqua(colors.accentPrimary), - colors.onAccentPrimary, - Color.White.copy(alpha = if (isLight) 0.9f else 0.3f), - ), - pressed = BaseUITheme.ButtonColors( - glassBrush( - lerp(colors.primary, Color.Black, 0.16f), - lerp(colors.primary, Color.Black, 0.24f), - ), - colors.onAccentPrimary, - glossSoft, - ), - focused = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), - ).withRole(SurfaceRole.PrimaryAccent) - val primaryShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 10.dp, - true, - colors.primary.copy(alpha = 0.35f), - colors.primary.copy(alpha = 0.42f), - ), - hovered = BaseUITheme.Shadow( - 16.dp, - true, - colors.primary.copy(alpha = 0.45f), - colors.primary.copy(alpha = 0.5f), - ), - pressed = BaseUITheme.Shadow( - 3.dp, - true, - colors.primary.copy(alpha = 0.22f), - colors.primary.copy(alpha = 0.28f), - ), - focused = BaseUITheme.Shadow( - 10.dp, - true, - colors.primary.copy(alpha = 0.35f), - colors.primary.copy(alpha = 0.42f), - ), - ) - val primaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(primaryEdge, 1.dp), - hovered = BaseUITheme.Border(primaryEdge, 1.dp), - pressed = BaseUITheme.Border(primaryEdge.copy(alpha = 0.8f), 1.dp), - focused = BaseUITheme.Border(primaryEdge, 1.dp), - ) - - // --- Secondary (soft aqua glass) ------------------------------------- - val secondaryBase = colors.backgroundSecondary - val secondaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), - colors.onSecondaryContainer, - gloss, - ), - hovered = BaseUITheme.ButtonColors( - glassBrush(lerp(secondaryBase, Color.White, 0.55f), secondaryBase), - colors.onSecondaryContainer, - Color.White.copy(alpha = if (isLight) 0.85f else 0.24f), - ), - pressed = BaseUITheme.ButtonColors( - glassBrush( - lerp(secondaryBase, Color.Black, 0.08f), - lerp(secondaryBase, Color.Black, 0.14f), - ), - colors.onSecondaryContainer, - glossSoft, - ), - focused = BaseUITheme.ButtonColors( - glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), - colors.onSecondaryContainer, - gloss, - ), - ).withRole(SurfaceRole.SecondaryBackground) - val secondaryShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 8.dp, - true, - secondaryBase.copy(alpha = 0.4f), - secondaryBase.copy(alpha = 0.5f), - ), - hovered = BaseUITheme.Shadow( - 14.dp, - true, - secondaryBase.copy(alpha = 0.5f), - secondaryBase.copy(alpha = 0.6f), - ), - pressed = BaseUITheme.Shadow( - 2.dp, - true, - secondaryBase.copy(alpha = 0.3f), - secondaryBase.copy(alpha = 0.35f), - ), - focused = BaseUITheme.Shadow( - 8.dp, - true, - secondaryBase.copy(alpha = 0.4f), - secondaryBase.copy(alpha = 0.5f), - ), - ) - val secondaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp), - hovered = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.6f), 1.dp), - pressed = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.4f), 1.dp), - focused = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp), - ) - - // --- Ghost ----------------------------------------------------------- - val ghostColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = glassBrush(Color.Transparent, Color.Transparent), - foreground = colors.onSurface, - highlight = Color.Transparent, - ), - hovered = BaseUITheme.ButtonColors( - background = glassBrush( - lerp(colors.surfaceContainerHigh, Color.White, 0.1f), - colors.surfaceContainer, - ), - foreground = colors.onSurface, - highlight = glossSoft, - ), - pressed = BaseUITheme.ButtonColors( - background = glassBrush(glassPressed, glassPressed), - foreground = colors.onSurface, - highlight = Color.Transparent, - ), - focused = BaseUITheme.ButtonColors( - background = glassBrush(Color.Transparent, Color.Transparent), - foreground = colors.onSurface, - highlight = Color.Transparent, - ), - ) - - val outlineStyle = BaseUITheme.ButtonStyle( - outlineColors, - defaultShapeState, - glassShadow, - glassBorderState, - defaultButtonPadding - ) - val primaryStyle = BaseUITheme.ButtonStyle( - primaryColors, - defaultShapeState, - primaryShadow, - primaryBorder, - defaultButtonPadding - ) - val secondaryStyle = BaseUITheme.ButtonStyle( - secondaryColors, - defaultShapeState, - secondaryShadow, - secondaryBorder, - defaultButtonPadding - ) - val ghostStyle = BaseUITheme.ButtonStyle( - ghostColors, - defaultShapeState, - noShadowState, - noBorderState, - defaultButtonPadding - ) - - val outlineIconStyle = BaseUITheme.ButtonStyle( - outlineColors, - defaultShapeState, - glassShadow, - glassBorderState, - iconButtonPadding - ) - val primaryIconStyle = BaseUITheme.ButtonStyle( - primaryColors, - defaultShapeState, - primaryShadow, - primaryBorder, - iconButtonPadding - ) - val secondaryIconStyle = BaseUITheme.ButtonStyle( - secondaryColors, - defaultShapeState, - secondaryShadow, - secondaryBorder, - iconButtonPadding - ) - val ghostIconStyle = BaseUITheme.ButtonStyle( - ghostColors, - defaultShapeState, - noShadowState, - noBorderState, - iconButtonPadding - ) - - val textFieldForeground = BaseUITheme.InteractionState( - normal = colors.onSurface, - hovered = colors.onSurface, - pressed = colors.onSurface, - focused = colors.onSurface, - ) - - val checkBoxShape = RoundedCornerShape(7.dp) - val checkBoxShapeState = - BaseUITheme.InteractionState(checkBoxShape, checkBoxShape, checkBoxShape, checkBoxShape) - val noPadding = - BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp)) - - val chipTabShape = RoundedCornerShape(999.dp) - val chipTabShapeState = - BaseUITheme.InteractionState(chipTabShape, chipTabShape, chipTabShape, chipTabShape) - val chipTabPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - ) - - val toggleShape = RoundedCornerShape(50) - val toggleShapeState = - BaseUITheme.InteractionState(toggleShape, toggleShape, toggleShape, toggleShape) - val togglePadding = BaseUITheme.InteractionState( - PaddingValues(2.dp), - PaddingValues(2.dp), - PaddingValues(2.dp), - PaddingValues(2.dp), - ) - - return BaseUITheme( - colors = colors, - surfaceTheme = surfaceTheme, - buttons = BaseUITheme.ButtonVariants( - primary = primaryStyle, - secondary = secondaryStyle, - outline = outlineStyle, - ghost = ghostStyle, - ), - iconButtons = BaseUITheme.ButtonVariants( - primary = primaryIconStyle, - secondary = secondaryIconStyle, - outline = outlineIconStyle, - ghost = ghostIconStyle, - ), - textField = TextFieldTheme( - background = BaseUITheme.InteractionState( - normal = glassBrush(panelTop, panelBottom), - hovered = glassBrush(panelTop, panelBottom), - pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(panelTop, panelBottom), - ), - highlight = BaseUITheme.InteractionState( - normal = gloss, - hovered = gloss, - pressed = glossSoft, - focused = Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), - ), - shape = BaseUITheme.InteractionState( - normal = defaultShape, - hovered = defaultShape, - pressed = defaultShape, - focused = defaultShape, - ), - border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(glassBorder, 1.dp), - hovered = BaseUITheme.Border(glassEdge, 1.dp), - pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp), - focused = BaseUITheme.Border(primaryEdge, 1.5.dp), - ), - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 8.dp, - true, - shadowColor.copy(alpha = 0.14f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 10.dp, - true, - shadowColor.copy(alpha = 0.18f), - shadowColor.copy(alpha = 0.22f) - ), - pressed = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.1f), - shadowColor.copy(alpha = 0.12f) - ), - focused = BaseUITheme.Shadow( - 10.dp, - true, - colors.primary.copy(alpha = 0.22f), - colors.primary.copy(alpha = 0.28f) - ), - ), - padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - textStyle = TextStyle.Default, - cursor = SolidColor(colors.primary), - foreground = textFieldForeground, - ), - autoCompleteMenuTheme = AutoCompleteMenuTheme( - background = glassBrush(panelTop, panelBottom), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(glassBorder, 1.dp), - padding = PaddingValues(0.dp), - shadowElevation = 16.dp, - ), - slider = SliderTheme( - trackActiveColor = BaseUITheme.InteractionState( - normal = colors.primary, - hovered = colors.primary, - pressed = colors.primary, - focused = colors.primary, - ), - trackInactiveColor = BaseUITheme.InteractionState( - normal = glassBorder, - hovered = glassBorder, - pressed = glassBorder, - focused = glassBorder, - ), - thumbColor = BaseUITheme.InteractionState( - normal = colors.primary, - hovered = colors.primary, - pressed = colors.primary, - focused = colors.primary, - ), - thumbShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 5.dp, - true, - colors.primary.copy(alpha = 0.4f), - colors.primary.copy(alpha = 0.45f) - ), - hovered = BaseUITheme.Shadow( - 8.dp, - true, - colors.primary.copy(alpha = 0.45f), - colors.primary.copy(alpha = 0.5f) - ), - pressed = BaseUITheme.Shadow( - 2.dp, - true, - colors.primary.copy(alpha = 0.25f), - colors.primary.copy(alpha = 0.3f) - ), - focused = BaseUITheme.Shadow( - 5.dp, - true, - colors.primary.copy(alpha = 0.4f), - colors.primary.copy(alpha = 0.45f) - ), - ), - stepActiveColor = Color.White.copy(alpha = 0.7f), - stepInactiveColor = colors.onSurface.copy(alpha = 0.25f), - ), - checkBox = CheckBoxTheme( - selected = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = checkBoxShapeState, - shadow = primaryShadow, - border = primaryBorder, - padding = noPadding, - ), - unselected = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = checkBoxShapeState, - shadow = glassShadow, - border = glassBorderState, - padding = noPadding, - ), - checkmarkColor = colors.onAccentPrimary, - ), - chipTab = ChipTabTheme( - selected = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = chipTabShapeState, - shadow = primaryShadow, - border = primaryBorder, - padding = chipTabPadding, - ), - unselected = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = chipTabShapeState, - shadow = glassShadow, - border = glassBorderState, - padding = chipTabPadding, - ), - ), - toggle = BaseUITheme.ToggleTheme( - checked = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = toggleShapeState, - shadow = primaryShadow, - border = primaryBorder, - padding = togglePadding, - ), - unchecked = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = toggleShapeState, - shadow = glassShadow, - border = glassBorderState, - padding = togglePadding, - ), - thumb = BaseUITheme.InteractionState( - normal = Color.White, - hovered = Color.White, - pressed = Color.White, - focused = Color.White, - ), - thumbShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - hovered = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.28f), - shadowColor.copy(alpha = 0.32f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.12f), - shadowColor.copy(alpha = 0.15f) - ), - focused = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - ), - ), - card = BaseUITheme.CardTheme( - background = glassBrush(secondaryTop, secondaryBottom), - shape = RoundedCornerShape(20.dp), - border = BaseUITheme.Border(glassBorder, 1.dp), - padding = PaddingValues(0.dp), - shadow = BaseUITheme.Shadow( - 12.dp, - true, - shadowColor.copy(alpha = 0.16f), - shadowColor.copy(alpha = 0.2f) - ), - surfaceRole = SurfaceRole.SecondaryBackground, - ), - listRowTile = BaseUITheme.ListRowTheme( - background = BaseUITheme.AdvancedInteractionState( - normal = glassBrush(Color.Transparent, Color.Transparent), - hovered = glassBrush(panelTop, panelBottom), - pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(panelTop, panelBottom), - selected = glassBrush(secondaryTop, secondaryBottom), - disabled = glassBrush( - panelTop.copy(alpha = 0.5f), - panelBottom.copy(alpha = 0.5f), - ), - ), - shape = BaseUITheme.AdvancedInteractionState( - normal = RoundedCornerShape(10.dp), - hovered = RoundedCornerShape(10.dp), - pressed = RoundedCornerShape(10.dp), - focused = RoundedCornerShape(10.dp), - selected = RoundedCornerShape(10.dp), - disabled = RoundedCornerShape(10.dp), - ), - border = BaseUITheme.AdvancedInteractionState( - normal = BaseUITheme.Border(Color.Transparent, 0.dp), - hovered = BaseUITheme.Border(glassBorder, 1.dp), - pressed = BaseUITheme.Border(Color.Transparent, 0.dp), - focused = BaseUITheme.Border(glassBorder, 1.dp), - selected = BaseUITheme.Border(Color.Transparent, 0.dp), - disabled = BaseUITheme.Border(Color.Transparent, 0.dp), - ), - shadow = BaseUITheme.AdvancedInteractionState( - normal = noShadow, - hovered = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.12f), - shadowColor.copy(alpha = 0.16f) - ), - pressed = noShadow, - focused = noShadow, - selected = noShadow, - disabled = noShadow, - ), - padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), - textStyle = BaseUITheme.AdvancedInteractionState( - normal = TextStyle.Default, - hovered = TextStyle.Default, - pressed = TextStyle.Default, - focused = TextStyle.Default, - selected = TextStyle.Default, - disabled = TextStyle.Default, - ), - foreground = BaseUITheme.AdvancedInteractionState( - normal = colors.onSurface, - hovered = colors.onSurface, - pressed = colors.onSurface, - focused = colors.onSurface, - selected = colors.onSurface, - disabled = colors.onSurface.copy(alpha = 0.38f), - ), - ), - dialog = BaseUITheme.DialogTheme( - background = glassBrush(panelTop, panelBottom), - scrim = Color.Black.copy(alpha = 0.42f), - shape = RoundedCornerShape(24.dp), - border = BaseUITheme.Border(glassEdge, 1.dp), - shadow = BaseUITheme.Shadow( - 28.dp, - true, - shadowColor.copy(alpha = 0.3f), - shadowColor.copy(alpha = 0.4f) - ), - padding = PaddingValues(24.dp), - ), - dropdownMenu = BaseUITheme.DropdownMenuTheme( - background = glassBrush(panelTop, panelBottom), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(glassBorder, 1.dp), - shadow = BaseUITheme.Shadow( - 16.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.28f) - ), - itemHoverBackground = glassBrush(panelTop, panelBottom), - itemForeground = colors.onSurface, - itemHighlight = gloss, - padding = PaddingValues(6.dp), - itemPadding = PaddingValues(horizontal = 14.dp, vertical = 11.dp), - ), - ) -} - -// Helper functions for easier theme overrides - -fun BaseUITheme.ButtonStyle.copyShape(shape: Shape): BaseUITheme.ButtonStyle = - copy(shape = BaseUITheme.InteractionState.fromSingleValue(shape)) - -fun BaseUITheme.ButtonStyle.copyShadow(shadow: BaseUITheme.Shadow): BaseUITheme.ButtonStyle = - copy(shadow = BaseUITheme.InteractionState.fromSingleValue(shadow)) - -fun BaseUITheme.ButtonStyle.copyBorder(border: BaseUITheme.Border): BaseUITheme.ButtonStyle = - copy(border = BaseUITheme.InteractionState.fromSingleValue(border)) - -fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.ButtonStyle = - copy(padding = BaseUITheme.InteractionState.fromSingleValue(padding)) - -fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle = - copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors)) - -/** Tags every interaction state of a button colour set with the surface role it renders. */ -private fun BaseUITheme.InteractionState.withRole( - role: SurfaceRole, -): BaseUITheme.InteractionState = BaseUITheme.InteractionState( - normal = normal.copy(surfaceRole = role), - hovered = hovered.copy(surfaceRole = role), - pressed = pressed.copy(surfaceRole = role), - focused = focused.copy(surfaceRole = role), -) - -@Composable -fun invertedButtonStyle(): BaseUITheme.ButtonStyle { - val scheme = MaterialTheme.colorScheme - val isLight = scheme.surface.luminance() > 0.5f - val shadowColor = scheme.onSurface - - val invertedColors = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.onSurface, scheme.onSurface)), - foreground = scheme.surface, - highlight = if (isLight) Color.White.copy(alpha = 0.15f) else Color.White.copy(alpha = 0.08f), - ) - val pressedColors = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - scheme.onSurfaceVariant, - scheme.onSurfaceVariant - ) - ), - foreground = scheme.surface, - highlight = if (isLight) Color.White.copy(alpha = 0.1f) else Color.White.copy(alpha = 0.05f), - ) - val border = BaseUITheme.Border(scheme.onSurfaceVariant, 0.5.dp) - val shadow = BaseUITheme.Shadow( - elevation = 6.dp, - clip = true, - ambientColor = shadowColor.copy(alpha = 0.2f), - spotColor = shadowColor.copy(alpha = 0.25f), - ) - - return BaseUITheme.ButtonStyle( - colors = BaseUITheme.InteractionState( - normal = invertedColors, - hovered = invertedColors, - pressed = pressedColors, - focused = invertedColors, - ), - shape = BaseUITheme.InteractionState.fromSingleValue(RoundedCornerShape(14.dp)), - shadow = BaseUITheme.InteractionState.fromSingleValue(shadow), - border = BaseUITheme.InteractionState.fromSingleValue(border), - padding = BaseUITheme.InteractionState.fromSingleValue(PaddingValues(8.dp)), - ) -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt deleted file mode 100644 index 607ef0cb..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max -import kotlin.math.roundToInt - -private val ToggleWidth = 44.dp -private val ToggleHeight = 24.dp -private val ThumbSize = 20.dp - -private data class ResolvedToggleState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveToggleState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedToggleState { - return when { - isPressed -> ResolvedToggleState( - style.colors.pressed, - style.shape.pressed, - style.shadow.pressed, - style.border.pressed, - ) - - isHovered -> ResolvedToggleState( - style.colors.hovered, - style.shape.hovered, - style.shadow.hovered, - style.border.hovered, - ) - - else -> ResolvedToggleState( - style.colors.normal, - style.shape.normal, - style.shadow.normal, - style.border.normal, - ) - } -} - -@Composable -fun Toggle( - checked: Boolean, - onCheckedChange: ((Boolean) -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - theme: BaseUITheme.ToggleTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.toggle - val toggleTheme = theme ?: baseTheme - val source = interactionSource ?: remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val style = if (checked) toggleTheme.checked else toggleTheme.unchecked - val resolved = resolveToggleState(style, isPressed, isHovered) - - val thumbColor = when { - isPressed -> toggleTheme.thumb.pressed - isHovered -> toggleTheme.thumb.hovered - else -> toggleTheme.thumb.normal - } - - val thumbShadowState = when { - isPressed -> toggleTheme.thumbShadow.pressed - isHovered -> toggleTheme.thumbShadow.hovered - else -> toggleTheme.thumbShadow.normal - } - - val borderColor = when { - !enabled -> resolved.colors.foreground.copy(alpha = 0.38f) - else -> resolved.border.color - } - - val thumbProgress by animateFloatAsState( - targetValue = if (checked) 1f else 0f, - animationSpec = tween(durationMillis = 200), - label = "thumbProgress", - ) - - val thumbScale by animateFloatAsState( - targetValue = when { - isPressed -> 0.92f - isHovered -> 1.05f - else -> 1f - }, - animationSpec = tween(durationMillis = 150), - label = "thumbScale", - ) - - val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp - val density = LocalDensity.current - val thumbOffsetXPx by animateDpAsState( - targetValue = with(density) { - val trackWidth = ToggleWidth - ThumbSize - max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp) - }, - animationSpec = tween(durationMillis = 200), - label = "thumbOffsetX", - ) - - Box( - modifier = modifier - .width(ToggleWidth) - .height(ToggleHeight) - .graphicsLayer { - translationY = lift.toPx() - } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = resolved.shape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(resolved.shape) - .background(resolved.colors.background, resolved.shape) - .applySurfaceMaterial(resolved.colors) - .border(BorderStroke(resolved.border.width, borderColor), resolved.shape) - .applyHighlight(resolved.colors) - .then( - if (onCheckedChange != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = source, - indication = null, - onClick = { onCheckedChange(!checked) }, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.CenterStart, - ) { - Box( - modifier = Modifier - .offset(x = thumbOffsetXPx) - .size(ThumbSize) - .graphicsLayer { - scaleX = thumbScale - scaleY = thumbScale - } - .shadow( - elevation = thumbShadowState.elevation, - shape = CircleShape, - ambientColor = thumbShadowState.ambientColor, - spotColor = thumbShadowState.spotColor, - ) - .background( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.6f), - thumbColor.copy(alpha = 0.95f), - thumbColor, - ), - center = Offset(0f, -0.55f), - radius = 1.1f, - ), - shape = CircleShape, - ) - .border( - width = 0.8.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.08f), - ), - ), - shape = CircleShape, - ), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun TogglePreview() { - BaseUIPreview { - var scrobbling by remember { mutableStateOf(true) } - var offlineMode by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - TogglePreviewRow( - label = "Scrobble listened tracks", - checked = scrobbling, - onCheckedChange = { scrobbling = it }, - ) - TogglePreviewRow( - label = "Offline mode", - checked = offlineMode, - onCheckedChange = { offlineMode = it }, - ) - TogglePreviewRow( - label = "Discord rich presence", - checked = true, - enabled = false, - onCheckedChange = {}, - ) - } - } -} - -@Composable -private fun TogglePreviewRow( - label: String, - checked: Boolean, - enabled: Boolean = true, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.bodyMedium) - Toggle( - checked = checked, - onCheckedChange = onCheckedChange, - enabled = enabled, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt deleted file mode 100644 index 96d44fd5..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt +++ /dev/null @@ -1,88 +0,0 @@ -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -fun Modifier.highlight(color: Color): Modifier = - drawWithCache { - // Frutiger-Aero glass: a bright specular sheen across the top half plus a - // faint reflection rising from the bottom edge. - val topGloss = Brush.verticalGradient( - colors = listOf(color, color.copy(alpha = color.alpha * 0.25f), Color.Transparent), - startY = 0f, - endY = size.height * 0.55f, - ) - val bottomReflection = Brush.verticalGradient( - colors = listOf(Color.Transparent, color.copy(alpha = color.alpha * 0.3f)), - startY = size.height * 0.72f, - endY = size.height, - ) - onDrawWithContent { - drawContent() - drawRect( - brush = topGloss, - topLeft = androidx.compose.ui.geometry.Offset.Zero, - size = size, - ) - drawRect( - brush = bottomReflection, - topLeft = androidx.compose.ui.geometry.Offset.Zero, - size = size, - ) - } - } - -@BaseUIPhonePreview -@Composable -private fun HighlightModifierPreview() { - BaseUIPreview { - var highlighted by remember { mutableStateOf(true) } - - Row( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Box( - modifier = Modifier - .size(96.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.primaryContainer) - .then( - if (highlighted) { - Modifier.highlight(Color.White.copy(alpha = 0.45f)) - } else { - Modifier - }, - ) - .clickable { highlighted = !highlighted }, - ) - Box( - modifier = Modifier - .size(96.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt index 07131282..24b21b1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,22 +33,19 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.Iconsax -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AdaptiveDialogBottomSheet( onDismiss: () -> Unit, @@ -49,51 +53,37 @@ fun AdaptiveDialogBottomSheet( modifier: Modifier = Modifier, breakpointDp: Float = 600f, actions: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val adaptiveInfo = currentWindowAdaptiveInfo() val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp if (isLargeScreen) { - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = if (title != null) { - { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Box(Modifier.weight(1f)) { title() } - IconButton(onClick = onDismiss) { - Icon( - Iconsax.FluentDismiss, - contentDescription = "Close dialog", - ) - } - } - } - } else { - null - }, - actions = actions, - modifier = modifier, - ) { + title = "", + modifier = modifier) { + title?.let { it() } content() + actions?.let { + MillenniumSeparator() + it() + } } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - modifier = modifier, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 16.dp), - horizontalAlignment = Alignment.Start, - ) { + horizontalAlignment = Alignment.Start) { title?.let { Row( modifier = Modifier.fillMaxWidth(), @@ -101,11 +91,12 @@ fun AdaptiveDialogBottomSheet( verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f)) { it() } - IconButton(onClick = onDismiss) { - Icon( + MillenniumIconButton( + onClick = onDismiss, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.FluentDismiss, - contentDescription = "Close dialog", - ) + contentDescription = "Close dialog") } } Spacer(Modifier.height(12.dp)) @@ -113,7 +104,7 @@ fun AdaptiveDialogBottomSheet( content() actions?.let { Spacer(Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + MillenniumSeparator() Spacer(Modifier.height(8.dp)) it() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index 352f0282..acb5b3a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -24,6 +31,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,16 +46,22 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.DropdownMenuPanel +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.UnstyledDropdownMenu +import com.composeunstyled.UnstyledDropdownMenuItem +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.gelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,10 +78,6 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import compose.icons.FeatherIcons import compose.icons.feathericons.Check -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.DropdownMenu -import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem -import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch @@ -83,8 +93,7 @@ data class AdaptiveMenuItem( val selected: Boolean = false, val dividerBefore: Boolean = false, val subtitle: String? = null, - val leadingContent: (@Composable () -> Unit)? = null, -) + val leadingContent: (@Composable () -> Unit)? = null) enum class HeaderDisplayMode { Always, @@ -101,69 +110,127 @@ fun AdaptiveDropdownBottomSheet( menuMinWidth: Dp = 200.dp, header: @Composable (() -> Unit)? = null, headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { var expanded by remember { mutableStateOf(false) } val adaptiveInfo = currentWindowAdaptiveInfo() val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp Box(modifier = modifier) { - trigger { expanded = true } - if (isLargeScreen) { - DropdownMenu( + UnstyledDropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { - header() - } - - var query by remember { mutableStateOf("") } - if (filter != null) { - TextField( - value = query, - onValueChange = { - query = it - }, - placeholder = { Text("Search...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - - items.forEach { item -> - if (query.isNotBlank() && filter != null && !filter(item, query)) { - return@forEach + onExpandedChange = { expanded = it }, + panel = { + DropdownMenuPanel( + Modifier + .width(menuMinWidth) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.medium, + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp) + .padding(4.dp)) { + if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { + header() } - if (item.dividerBefore) { - DropdownMenuDivider() + + val dropdownQueryState = rememberTextFieldState() + val query = dropdownQueryState.text.toString() + if (filter != null) { + MillenniumTextField( + state = dropdownQueryState, + label = "", + placeholder = "Search...", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) + } + + items.forEach { item -> + if (query.isNotBlank() && filter != null && !filter(item, query)) { + return@forEach + } + if (item.dividerBefore) { + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) + } + val itemInteraction = remember { MutableInteractionSource() } + val itemHovered by itemInteraction.collectIsHoveredAsState() + val itemPressed by itemInteraction.collectIsPressedAsState() + val itemEnabled = item.enabled + val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected) + val itemForeground = when { + !itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f) + itemHighlighted -> MillenniumTheme.colors.onAccent + else -> AppTheme.colors.onSurface + } + UnstyledDropdownMenuItem( + onClick = { item.onClick() }, + enabled = itemEnabled, + indication = null, + interactionSource = itemInteraction) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (itemHighlighted) { + Modifier.millenniumSurface( + background = gelBrush( + MillenniumTheme.colors.accentTop, + MillenniumTheme.colors.accentBottom), + shape = RoundedCornerShape(8.dp)) + } else { + Modifier + }) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val itemIcon = item.icon + when { + item.leadingContent != null -> item.leadingContent.invoke() + itemIcon != null -> MillenniumIcon( + imageVector = itemIcon, + contentDescription = null, + tint = itemForeground, + modifier = Modifier.size(20.dp)) + + item.selected -> MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = "Selected", + tint = itemForeground, + modifier = Modifier.size(20.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label, + style = AppTheme.typography.bodyMedium, + color = itemForeground) + item.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = if (itemHighlighted) { + itemForeground.copy(alpha = 0.85f) + } else { + AppTheme.colors.onSurfaceVariant + }) + } + } + if (item.selected && (item.icon != null || item.leadingContent != null)) { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = "Selected", + tint = itemForeground, + modifier = Modifier.size(20.dp)) + } + } + } } - DropdownMenuItem( - text = item.label, - onClick = { - item.onClick() - expanded = false - }, - enabled = item.enabled, - selected = item.selected, - leadingIcon = item.icon, - leadingContent = item.leadingContent, - subtitle = item.subtitle, - ) } - } + }, + anchor = { trigger { expanded = true } }, + ) } else { + trigger { expanded = true } if (expanded) { AdaptiveBottomSheetContent( onDismiss = { expanded = false }, @@ -173,119 +240,97 @@ fun AdaptiveDropdownBottomSheet( } else { null }, - filter = filter, - ) + filter = filter) } } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AdaptiveBottomSheetContent( onDismiss: () -> Unit, items: List, header: @Composable (() -> Unit)?, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { val hasSelection = items.any { it.selected } - var query by remember { mutableStateOf("") } + val queryState = rememberTextFieldState() + val query = queryState.text.toString() val filteredItems = if (filter != null && query.isNotBlank()) { items.filter { filter(it, query) } } else { items } - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { Column( - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { header?.invoke() if (filter != null) { Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = query, - onValueChange = { - query = it - }, - placeholder = { Text("Search...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, + MillenniumTextField( + state = queryState, + label = "", + placeholder = "Search...", modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) + .padding(horizontal = 12.dp, vertical = 8.dp)) } LazyColumn( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { items( - count = filteredItems.size, - ) { index -> + count = filteredItems.size) { index -> val item = filteredItems[index] if (item.dividerBefore) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) } Row( modifier = Modifier .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) + .clip(AppTheme.shapes.medium) .clickable( enabled = item.enabled, onClick = { item.onClick() onDismiss() - }, - ) + }) .background( color = if (item.selected) { - MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f) + AppTheme.colors.secondary.copy(alpha = 0.12f) } else { Color.Transparent }, - shape = MaterialTheme.shapes.medium, - ) + shape = AppTheme.shapes.medium) .padding(horizontal = 12.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { when { item.leadingContent != null -> item.leadingContent.invoke() - item.icon != null -> Icon( + item.icon != null -> MillenniumIcon( imageVector = item.icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) - item.selected -> Icon( + item.selected -> MillenniumIcon( imageVector = FeatherIcons.Check, contentDescription = "Selected", modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) hasSelection -> Spacer(modifier = Modifier.size(22.dp)) } @@ -293,29 +338,26 @@ private fun AdaptiveBottomSheetContent( Column(modifier = Modifier.weight(1f)) { Text( text = item.label, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, color = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) item.subtitle?.let { subtitle -> Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } if (item.selected && (item.icon != null || item.leadingContent != null)) { - Icon( + MillenniumIcon( imageVector = FeatherIcons.Check, contentDescription = "Selected", modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } @@ -327,44 +369,37 @@ private fun AdaptiveBottomSheetContent( @Preview @Composable private fun AdaptiveDropdownBottomSheetPreview() { - BaseScaffold { + Box(modifier = Modifier.background(AppTheme.colors.background)) { AdaptiveDropdownBottomSheet( items = listOf( AdaptiveMenuItem( icon = Iconsax.IconsaxHeart, label = "Favorite", - onClick = {}, - ), + onClick = {}), AdaptiveMenuItem( icon = Iconsax.IconsaxShare, label = "Share", - onClick = {}, - ), + onClick = {}), AdaptiveMenuItem( icon = Iconsax.IconsaxTrash, label = "Delete", onClick = {}, - enabled = false, - ), - ), + enabled = false)), trigger = { onClick -> - IconButton( + MillenniumIconButton( onClick = onClick, modifier = Modifier .size(32.dp) .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - shape = CircleShape, - ) + color = AppTheme.colors.primary.copy(alpha = 0.1f), + shape = CircleShape) .clip(CircleShape), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } - }, - ) + }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 769b8c43..23599f4e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -40,8 +40,7 @@ fun AlbumCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject(), -) { + remotePlaybackController: RemotePlaybackController = koinInject()) { val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( @@ -57,17 +56,14 @@ fun AlbumCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Album, album.id, - album.title, - ) + album.title) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Album, album.id, - album.title, - ) + album.title) }, modifier = modifier.width(160.dp), - sharedElementKey = "album_art_${album.id}", - ) + sharedElementKey = "album_art_${album.id}") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt index 30cfa7b7..2b40616e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt @@ -17,9 +17,12 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton + import androidx.compose.runtime.Composable import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 @@ -30,12 +33,12 @@ import org.koin.compose.koinInject fun ApplicationBackButton() { val navigationCommands: NavigationCommands = koinInject() - IconButton( + MillenniumIconButton( onClick = { navigationCommands.pop() }, - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.ArrowLeft3, contentDescription = "Back" ) @@ -48,5 +51,4 @@ expect fun ApplicationMainBar( subtitle: @Composable () -> Unit = {}, actions: @Composable (RowScope.() -> Unit) = {}, backButton: Boolean = true, - transparent: Boolean = false, -) \ No newline at end of file + transparent: Boolean = false) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt index 4cb5922e..541facd1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt @@ -29,9 +29,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,8 +52,7 @@ import org.koin.compose.koinInject fun ArtistCard( artist: MetadataArtist.Basic, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false, -) { + isBlacklisted: Boolean = false) { val navigationCommands = koinInject() AvatarCard( @@ -64,7 +63,6 @@ fun ArtistCard( navigationCommands.navigateTo(Routes.Artist(artist.id)) }, modifier = modifier, - isBlacklisted = isBlacklisted, - ) + isBlacklisted = isBlacklisted) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 4b9ec5a9..2315074e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -17,13 +17,23 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.height +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,9 +41,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,12 +52,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.ButtonGroup -import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GroupIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -62,7 +66,6 @@ import dev.krtirtho.spotube.resources.iconsax.User import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource - @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun CollectionDetails( @@ -85,8 +88,7 @@ fun CollectionDetails( onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, - sharedElementKey: String? = null, -) { + sharedElementKey: String? = null) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) { val isCompact = maxWidth < 600.dp @@ -98,95 +100,86 @@ fun CollectionDetails( if (onAddToJam != null) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - PrimaryButton( + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MillenniumButton( modifier = if (isCompact) Modifier.weight(1f) else Modifier, - onClick = onAddToJam!!, - ) { - Icon( + onClick = onAddToJam!!) { + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to Jam", - ) - TextWithShimmer( - text = "Add to Jam", - modifier = Modifier.padding(start = 6.dp), - ) + contentDescription = "Add to Jam") + TextWithShimmer(text = "Add to Jam") } } } } else { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(6.dp)) { val modifier = if (isCompact) { Modifier.weight(1f) } else { Modifier } - PrimaryButton( + MillenniumButton( modifier = modifier, - onClick = onPlay, - ) { - Icon( + onClick = onPlay) { + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - TextWithShimmer( - text = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(start = 6.dp), - ) + contentDescription = if (isPlaying) "Pause" else "Play") + TextWithShimmer(text = if (isPlaying) "Pause" else "Play") } - OutlineButton( + MillenniumButton( modifier = modifier, - onClick = onShufflePlay + onClick = onShufflePlay, + style = MillenniumButtonStyle.Secondary ) { - Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer( - text = "Shuffle", - modifier = Modifier.padding(start = 6.dp), - ) + MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") + TextWithShimmer(text = "Shuffle") } } } } val actions = @Composable { - ButtonGroup { - GroupIconButton(onClick = onAddToQueue) { - Icon( + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + onClick = onAddToQueue, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, contentDescription = "Add to queue" ) } - ButtonGroupDivider() + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) if (showFollowButton) { if (isFollowing) { - GroupIconButton(onClick = onFollowClick) { - Icon( + MillenniumIconButton( + onClick = onFollowClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxHeart2, - contentDescription = "Unfollow", - ) + contentDescription = "Unfollow") } } else { - GroupIconButton(onClick = onFollowClick) { - Icon( + MillenniumIconButton( + onClick = onFollowClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxHeart, contentDescription = "Follow", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } if (onEdit != null) { - ButtonGroupDivider() - GroupIconButton(onClick = onEdit) { - Icon( + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) + MillenniumIconButton( + onClick = onEdit, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxEdit, - contentDescription = "Edit playlist", - ) + contentDescription = "Edit playlist") } } } @@ -195,8 +188,7 @@ fun CollectionDetails( Row( modifier = Modifier.clickable(onClick = onOwnerClick), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(10.dp)) { if (ownerImageURL != null) { AsyncImage( model = ownerImageURL, @@ -205,27 +197,24 @@ fun CollectionDetails( .size(16.dp) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.User, contentDescription = ownerName, modifier = Modifier .size(16.dp) .background( - MaterialTheme.colorScheme.primaryContainer, + AppTheme.colors.primaryContainer, CircleShape ) .padding(6.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) + tint = AppTheme.colors.onPrimaryContainer) } TextWithShimmer( text = "By $ownerName", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } } @@ -237,79 +226,68 @@ fun CollectionDetails( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) .shimmerApply(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (imageURL.isNotBlank()) { AsyncImage( model = imageURL, contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else if (imageResource != null) { androidx.compose.foundation.Image( painter = painterResource(imageResource), contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = title.take(1).ifBlank { "?" }.uppercase(), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.displaySmall, + color = AppTheme.colors.onSurfaceVariant) } } } - val metadata = @Composable { Column { TextWithShimmer( text = title, - style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge, + style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) if (description.isNotBlank()) { TextWithShimmer( text = description, - style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } - Card( + MillenniumCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - ) { + contentPadding = PaddingValues(0.dp)) { if (isCompact) { Column( modifier = Modifier .fillMaxWidth() .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { artwork() Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + verticalArrangement = Arrangement.spacedBy(6.dp)) { metadata() ownerInfo() actions() @@ -323,14 +301,12 @@ fun CollectionDetails( .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { artwork() Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { metadata() ownerInfo() actions() @@ -354,6 +330,5 @@ fun CollectionDetailsPreview() { onOwnerClick = {}, onPlay = {}, onShufflePlay = {}, - onAddToQueue = {}, - ) + onAddToQueue = {}) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 614999e6..5434ce34 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -17,18 +17,22 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -93,8 +97,7 @@ fun CollectionView( onAddToJam: (List) -> Unit = {}, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, - trailingContent: @Composable () -> Unit = {}, -) { + trailingContent: @Composable () -> Unit = {}) { val listState = rememberLazyListState() val isCollapsed by remember { derivedStateOf { @@ -103,45 +106,42 @@ fun CollectionView( } } - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = if (isCollapsed) { - { - CollapsedCollectionTitle( - title = title, - imageURL = imageURL, - imageResource = imageResource, - ) + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = if (isCollapsed) { + { + CollapsedCollectionTitle( + title = title, + imageURL = imageURL, + imageResource = imageResource) + } + } else { + {} + }, + actions = if (isCollapsed && !isJamGuest) { + { + MillenniumIconButton( + onClick = onPlay, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) "Pause" else "Play") } - } else { - {} - }, - actions = if (isCollapsed && !isJamGuest) { - { - IconButton(onClick = onPlay) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - } - } - } else { - {} - }, - ) - } - ) { innerPadding -> + } + } else { + {} + }) + + Box(modifier = Modifier.weight(1f).fillMaxSize()) { if (error != null && tracks.isEmpty() && !isLoading) { ErrorDisplay( errorMessage = error, onRetry = onRetry ?: {}, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.fillMaxSize()) } else { TrackList( - modifier = Modifier.padding(innerPadding), + modifier = Modifier.fillMaxSize(), state = listState, headerContent = { if (isLoading && tracks.isEmpty()) { @@ -164,8 +164,7 @@ fun CollectionView( onEdit = onEdit, sharedElementKey = sharedElementKey, isJamGuest = isJamGuest, - onAddToJam = { onAddToJam(tracks) }, - ) + onAddToJam = { onAddToJam(tracks) }) } } else { CollectionDetails( @@ -186,8 +185,7 @@ fun CollectionView( onEdit = onEdit, sharedElementKey = sharedElementKey, isJamGuest = isJamGuest, - onAddToJam = { onAddToJam(tracks) }, - ) + onAddToJam = { onAddToJam(tracks) }) } }, footerContent = footerContent, @@ -211,11 +209,11 @@ fun CollectionView( onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, isJamGuest = isJamGuest, - trackOptionsState = trackOptionsState, - ) + trackOptionsState = trackOptionsState) } trailingContent() + } } } @@ -223,46 +221,39 @@ fun CollectionView( private fun CollapsedCollectionTitle( title: String, imageURL: String, - imageResource: DrawableResource? = null, -) { + imageResource: DrawableResource? = null) { Row( - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .shimmerApply(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (imageURL.isNotBlank()) { AsyncImage( model = imageURL, contentDescription = title, modifier = Modifier.size(36.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else if (imageResource != null) { androidx.compose.foundation.Image( painter = painterResource(imageResource), contentDescription = title, modifier = Modifier.size(36.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = title.take(1).ifBlank { "?" }.uppercase(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 10.dp), - ) + modifier = Modifier.padding(start = 10.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt index 7038d5b4..be7fed6b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt @@ -17,30 +17,33 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw fun ErrorDisplay( errorMessage: String, onRetry: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { var showFullLog by remember { mutableStateOf(false) } val clipboardManager = LocalClipboardManager.current @@ -77,117 +79,99 @@ fun ErrorDisplay( .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - Icon( + verticalArrangement = Arrangement.spacedBy(20.dp)) { + MillenniumIcon( imageVector = FeatherIcons.AlertCircle, contentDescription = null, modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), - ) + tint = AppTheme.colors.error.copy(alpha = 0.7f)) Text( text = "We're Truly Sorry", - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) Text( text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2, - ) + lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2) - Card( + MillenniumCard( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), - ), - shape = MaterialTheme.shapes.medium, - ) { + contentPadding = PaddingValues(0.dp)) { Column( - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = "Error Details", - style = MaterialTheme.typography.labelMedium, + style = AppTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + color = AppTheme.colors.onSurfaceVariant) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton( + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MillenniumIconButton( onClick = { clipboardManager.setText(AnnotatedString(errorMessage)) }, modifier = Modifier.size(32.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = FeatherIcons.Copy, contentDescription = "Copy to clipboard", - modifier = Modifier.size(16.dp), - ) + modifier = Modifier.size(16.dp)) } - IconButton( + MillenniumIconButton( onClick = { showFullLog = true }, modifier = Modifier.size(32.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = FeatherIcons.Maximize2, contentDescription = "View full log", - modifier = Modifier.size(16.dp), - ) + modifier = Modifier.size(16.dp)) } } } - Surface( + MillenniumCard( modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f), - ) { + contentPadding = PaddingValues(0.dp)) { SelectionContainer { Text( text = errorMessage, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, maxLines = 5, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(12.dp), - ) + modifier = Modifier.padding(12.dp)) } } } } Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Button( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumButton( onClick = onRetry, - modifier = Modifier.height(40.dp), - ) { - Icon( - imageVector = FeatherIcons.RefreshCw, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp).size(18.dp), - ) - Text("Retry") + modifier = Modifier.height(40.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.RefreshCw, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text("Retry") + } } } } @@ -195,93 +179,85 @@ fun ErrorDisplay( if (showFullLog) { ErrorLogDialog( errorMessage = errorMessage, - onDismiss = { showFullLog = false }, - ) + onDismiss = { showFullLog = false }) } } @Composable private fun ErrorLogDialog( errorMessage: String, - onDismiss: () -> Unit, -) { + onDismiss: () -> Unit) { val clipboardManager = LocalClipboardManager.current - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Full Error Log", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) + title = "") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Full Error Log", + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.Bold) - IconButton( - onClick = { - clipboardManager.setText(AnnotatedString(errorMessage)) - }, - ) { - Icon( - imageVector = FeatherIcons.Copy, - contentDescription = "Copy to clipboard", - ) - } + MillenniumIconButton( + onClick = { + clipboardManager.setText(AnnotatedString(errorMessage)) + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Copy, + contentDescription = "Copy to clipboard") } - }, - text = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(400.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)), - ) { - SelectionContainer { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(12.dp), - ) - } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) { + SelectionContainer { + Text( + text = errorMessage, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp)) } - }, - confirmButton = { - TextButton(onClick = onDismiss) { + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Quiet) { Text("Close") } - }, - ) + } + } } @Preview @Composable private fun ErrorDisplayPreview() { - Surface { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { ErrorDisplay( errorMessage = "This is an error message", - onRetry = {}, - ) + onRetry = {}) } } @Preview @Composable private fun ErrorLogDialogPreview() { - BaseScaffold { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { ErrorLogDialog( errorMessage = "This is an error message", - onDismiss = {}, - ) + onDismiss = {}) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt index 810c69e4..75f09d3a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -34,11 +38,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -83,8 +86,7 @@ import org.koin.compose.koinInject @Composable fun FeaturedCarousel( items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val filteredItems = remember(items) { items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track } } @@ -92,15 +94,13 @@ fun FeaturedCarousel( if (filteredItems.isEmpty()) return FeaturedCarouselContent( - items = filteredItems, - ) + items = filteredItems) } @Composable private fun FeaturedCarouselContent( items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val centerIndex = items.size / 2 var selectedIndex by remember { mutableIntStateOf(centerIndex) } val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index } @@ -113,8 +113,7 @@ private fun FeaturedCarouselContent( .height( if (isSmallScreen) 200.dp else 300.dp ), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Coverflow( state = state, params = CoverflowParams( @@ -123,21 +122,17 @@ private fun FeaturedCarouselContent( angle = 12f, shift = 0.255f, zoom = 0.7864f, - mirror = false, - ), - modifier = modifier, - ) { + mirror = false), + modifier = modifier) { items( items = items, - key = { index: Int -> items[index].hashCode() }, - ) { item: MetadataBrowseItem -> + key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem -> FeaturedCarouselCard( item = item, isCentered = selectedIndex == items.indexOf(item), onScrollToCenter = { state.scrollToItem(items.indexOf(item)) - }, - ) + }) } } } @@ -148,8 +143,7 @@ private fun FeaturedCarouselCard( item: MetadataBrowseItem, isCentered: Boolean, modifier: Modifier = Modifier, - onScrollToCenter: () -> Unit = { }, -) { + onScrollToCenter: () -> Unit = { }) { val navigationCommands = koinInject() val audioPlayerQueue = koinInject() @@ -204,25 +198,21 @@ private fun FeaturedCarouselCard( } } .aspectRatio(1f) - .hazeSource(hazeState), - ) { + .hazeSource(hazeState)) { AsyncImage( model = thumbnailUrl, contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = dimAlpha)), - ) + .background(Color.Black.copy(alpha = dimAlpha))) Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { + contentAlignment = Alignment.BottomCenter) { Box( modifier = Modifier .fillMaxWidth() @@ -231,43 +221,35 @@ private fun FeaturedCarouselCard( brush = Brush.verticalGradient( colors = listOf( Color.Transparent, - Color.Black.copy(alpha = 0.9f), - ), - ), - ), - ) + Color.Black.copy(alpha = 0.9f))))) } } Box( modifier = Modifier .fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { + contentAlignment = Alignment.BottomCenter) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .padding(16.dp), - ) { + .padding(16.dp)) { Column(modifier = Modifier.weight(1f)) { Text( text = title, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, + style = AppTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.85f), maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } val animatedAlpha by animateFloatAsState( @@ -306,8 +288,7 @@ private fun FeaturedPlayButton( navigationCommands: NavigationCommands, playbackHelper: CollectionPlaybackHelper = koinInject(), enabled: Boolean, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -320,7 +301,8 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { if (isPlaying) { navigationCommands.navigateTo(Routes.Album(item.data.id)) @@ -330,7 +312,7 @@ private fun FeaturedPlayButton( }, enabled = enabled ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White @@ -347,7 +329,8 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { if (isPlaying) { navigationCommands.navigateTo(Routes.Playlist(item.data.id)) @@ -357,7 +340,7 @@ private fun FeaturedPlayButton( }, enabled = enabled ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White @@ -377,20 +360,20 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { scope.launch { audioPlayerQueue.load( entries = listOf(queueEntry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } }, enabled = enabled ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt index eca9b2e2..ac3444e8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn @@ -35,11 +39,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -76,8 +78,7 @@ fun LikedTracksCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject(), -) { + navigationCommands: NavigationCommands = koinInject()) { val scope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() @@ -92,8 +93,7 @@ fun LikedTracksCard( .clip(RoundedCornerShape(8.dp)) .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) .width(160.dp) - .hoverable(interactionSource = interactionSource), - ) { + .hoverable(interactionSource = interactionSource)) { Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) { androidx.compose.foundation.Image( @@ -106,10 +106,8 @@ fun LikedTracksCard( .sharedElementOrNone( key = "saved_tracks_art", sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ), - contentScale = ContentScale.Crop, - ) + animatedVisibilityScope = animatedVisibilityScope), + contentScale = ContentScale.Crop) Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), @@ -120,22 +118,24 @@ fun LikedTracksCard( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { - FilledTonalIconButton( + MillenniumIconButton( onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, contentDescription = "Add to queue", modifier = Modifier.size(16.dp) ) } Spacer(modifier = Modifier.height(8.dp)) - FilledIconButton( + MillenniumIconButton( onClick = { scope.launch { playbackHelper.playSavedTracks() } }, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Primary ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) { Iconsax.IconsaxPauseCircle } else Iconsax.Play, @@ -152,18 +152,16 @@ fun LikedTracksCard( ) { Text( text = "Liked Tracks", - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) Text( text = "Your saved songs", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index cf83ec60..ffacbb4d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -40,8 +40,7 @@ fun PlaylistCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject(), -) { + remotePlaybackController: RemotePlaybackController = koinInject()) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -58,17 +57,14 @@ fun PlaylistCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Playlist, playlist.id, - playlist.title, - ) + playlist.title) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Playlist, playlist.id, - playlist.title, - ) + playlist.title) }, modifier = modifier, - sharedElementKey = "playlist_art_${playlist.id}", - ) + sharedElementKey = "playlist_art_${playlist.id}") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt index 64c05db0..60de716c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt @@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf fun Modifier.sharedElementOrNone( key: String?, sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, -): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this with(sharedTransitionScope) { return this@sharedElementOrNone.sharedElement( sharedContentState = rememberSharedContentState(key = key), - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt index aae13619..755dc1bd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt @@ -17,33 +17,33 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import com.composeunstyled.LocalTextStyle +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.copyPadding -import dev.krtirtho.spotube.core.ui.base.copyShape /** * The shared navigation item used by every sidebar in the app (the main app sidebar @@ -60,64 +60,57 @@ fun SidebarItem( onClick: () -> Unit, modifier: Modifier = Modifier, expanded: Boolean = true, - badge: (@Composable BoxScope.() -> Unit)? = null, -) { - val itemContent: @Composable RowScope.() -> Unit = { - Box(modifier = Modifier.size(16.dp)) { - Icon( - imageVector = icon, - contentDescription = label, - tint = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - badge?.invoke(this) - } - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { - Text( - text = label, - maxLines = 1, - softWrap = false, - style = LocalTextStyle.current.copy( - fontSize = MaterialTheme.typography.labelMedium.fontSize, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - color = if (selected) { - MaterialTheme.colorScheme.onSecondaryContainer + badge: (@Composable BoxScope.() -> Unit)? = null) { + val itemContent: @Composable () -> Unit = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(16.dp)) { + MillenniumIcon( + imageVector = icon, + contentDescription = label, + tint = if (selected) { + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + AppTheme.colors.onSurfaceVariant + }) + badge?.invoke(this) + } + AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { + Text( + text = label, + singleLine = true, + style = LocalTextStyle.current.copy( + fontSize = AppTheme.typography.labelMedium.fontSize, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + color = if (selected) { + AppTheme.colors.onSecondaryContainer + } else { + AppTheme.colors.onSurfaceVariant + } + ) ) - ) + } + Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) } - Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) } val buttonModifier = modifier .fillMaxWidth() .padding(horizontal = 7.dp, vertical = 2.dp) - val contentPadding = PaddingValues(horizontal = 5.dp) if (selected) { - SecondaryButton( + MillenniumButton( onClick = onClick, modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.secondary - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(5.dp)), - content = itemContent, - ) + style = MillenniumButtonStyle.Secondary, + content = itemContent) } else { - OutlineButton( + MillenniumButton( onClick = onClick, modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.outline - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(5.dp)), - hoverOnly = true, - content = itemContent, - ) + style = MillenniumButtonStyle.Quiet, + content = itemContent) } } @@ -127,16 +120,14 @@ fun SidebarItem( @Composable fun SidebarSectionLabel( text: String, - expanded: Boolean = true, -) { + expanded: Boolean = true) { AnimatedVisibility(visible = expanded) { Text( text = text.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - ) + .padding(horizontal = 24.dp, vertical = 8.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt index 4d81fd73..57cf1f4e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt @@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -54,8 +51,7 @@ import org.koin.compose.koinInject fun TrackCard( track: MetadataTrack, modifier: Modifier = Modifier, - audioPlayerQueue: AudioPlayerQueue = koinInject(), -) { + audioPlayerQueue: AudioPlayerQueue = koinInject()) { val scope = rememberCoroutineScope() val queueEntry = QueueEntry.StreamingTrack(track = track, url = "") @@ -69,8 +65,7 @@ fun TrackCard( entries = listOf(queueEntry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } }, onAddToQueue = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index c092a364..7fbd1f4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -36,6 +37,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -44,12 +46,20 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.state.ToggleableState +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumCheckbox +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTriStateCheckbox + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -83,16 +93,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumTy import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.ButtonGroup -import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.CheckBox -import dev.krtirtho.spotube.core.ui.base.CheckBoxState -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.GroupIconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.highlight import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply @@ -122,7 +122,7 @@ enum class TrackSortOption(val label: String) { Duration("Duration"), } -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun TrackList( tracks: List = emptyList(), @@ -153,9 +153,9 @@ fun TrackList( contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), simplified: Boolean = false, scrollable: Boolean = true, - state: LazyListState? = null, -) { - var filterQuery by rememberSaveable { mutableStateOf("") } + state: LazyListState? = null) { + val filterState = rememberTextFieldState() + val filterQuery = filterState.text.toString() var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var selectedTrackForOptions by remember { mutableStateOf(null) } var isSelectionMode by rememberSaveable { mutableStateOf(false) } @@ -216,11 +216,11 @@ fun TrackList( val useDropdownForOptions = isDesktop && !isCompact val trackCardContent: @Composable () -> Unit = { - Card( + MillenniumCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - ) { + contentPadding = PaddingValues(0.dp)) { Column( modifier = Modifier .fillMaxWidth() @@ -228,9 +228,7 @@ fun TrackList( ) { visibleTracks.forEachIndexed { displayedIndex, track -> if (displayedIndex > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator() } TrackListRow( index = displayedIndex + 1, @@ -284,38 +282,31 @@ fun TrackList( onShowOptionsClick = { selectedTrackForOptions = track }, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, - onArtistsOverflowClick = { onArtistsOverflowClick(track) }, - ) + onArtistsOverflowClick = { onArtistsOverflowClick(track) }) } if (isLoading && tracks.isEmpty()) { repeat(ShimmerRowCount) { shimmerIndex -> if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) } ShimmerTrackListRow( index = shimmerIndex + 1, showIndex = showIndex, showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) + useDropdownForOptions = useDropdownForOptions) } } if (error != null) { if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) } TrackListFeedbackRow( message = error, - isError = true, - ) + isError = true) } if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { @@ -324,17 +315,14 @@ fun TrackList( if (isLoadingNextPage) { if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) } ShimmerTrackListRow( index = visibleTracks.size + 1, showIndex = showIndex, showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) + useDropdownForOptions = useDropdownForOptions) } } } @@ -345,19 +333,18 @@ fun TrackList( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) .heightIn(max = 60.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), - ) { + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) { if (showIndex || isSelectionMode) { val allSelected = visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size val anySelected = selectedTrackIds.isNotEmpty() val headerState = when { - allSelected -> CheckBoxState.SELECTED - anySelected -> CheckBoxState.INDETERMINATE - else -> CheckBoxState.UNSELECTED + allSelected -> ToggleableState.On + anySelected -> ToggleableState.Indeterminate + else -> ToggleableState.Off } - CheckBox( - state = headerState, + MillenniumTriStateCheckbox( + value = headerState, onClick = { if (allSelected) { isSelectionMode = false @@ -367,58 +354,45 @@ fun TrackList( selectedTrackIds = visibleTracks.map { it.id }.toSet() } }, - ) + label = "") } Box( modifier = Modifier .weight(1f) ) { - TextField( - value = filterQuery, - onValueChange = { filterQuery = it }, + MillenniumTextField( + state = filterState, + label = "", + placeholder = "Filter", modifier = Modifier .widthIn(max = 400.dp) - .align(Alignment.CenterEnd), - placeholder = { TextWithShimmer("Filter") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Filter", - ) - }, - singleLine = true, - ) + .align(Alignment.CenterEnd)) } - ButtonGroup { + Row(verticalAlignment = Alignment.CenterVertically) { Box( - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { AdaptiveDropdownBottomSheet( items = TrackSortOption.entries.map { option -> AdaptiveMenuItem( label = option.label, onClick = { sortBy = option }, - selected = sortBy == option, - ) + selected = sortBy == option) }, trigger = { onClick -> - GroupIconButton( + MillenniumIconButton( onClick = onClick, - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxSort, - contentDescription = "Sort", - ) + contentDescription = "Sort") } - }, - ) + }) } - ButtonGroupDivider() + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) Box( - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { val targetTracks = if (selectedTrackIds.isNotEmpty()) { visibleTracks.filter { selectedTrackIds.contains(it.id) } } else { @@ -433,54 +407,42 @@ fun TrackList( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, label = if (isAll) "Download All" else "Download $trackCount", - onClick = { onBulkDownload(targetTracks) }, - ), - ) + onClick = { onBulkDownload(targetTracks) })) if (!isJamGuest) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - ) + onClick = { onBulkAddToQueue(targetTracks) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - ) + onClick = { onBulkPlayNext(targetTracks) })) } add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicPlaylist, label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", - onClick = { onBulkAddToPlaylist(targetTracks) }, - ), - ) + onClick = { onBulkAddToPlaylist(targetTracks) })) } + if (isInJam) { listOf( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", - onClick = { onBulkAddToJam(targetTracks) }, - ), - ) + onClick = { onBulkAddToJam(targetTracks) })) } else { emptyList() }, trigger = { onClick -> - GroupIconButton( + MillenniumIconButton( onClick = onClick, - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Bulk actions", - ) + contentDescription = "Bulk actions") } - }, - ) + }) } } } @@ -495,8 +457,7 @@ fun TrackList( .align(Alignment.TopCenter) .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { if (headerContent != null) { item { headerContent() @@ -525,8 +486,7 @@ fun TrackList( }, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, isInJam = isInJam, - isJamGuest = isJamGuest, - ) + isJamGuest = isJamGuest) } } VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) @@ -536,8 +496,7 @@ fun TrackList( modifier = modifier .fillMaxWidth() .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { if (headerContent != null) { headerContent() } @@ -560,8 +519,7 @@ fun TrackList( onTrackOptionsAction(track, action) selectedTrackForOptions = null }, - onAlbumClick = { track.album?.let { onAlbumClick(it) } }, - ) + onAlbumClick = { track.album?.let { onAlbumClick(it) } }) } } } @@ -590,235 +548,198 @@ private fun TrackListRow( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val rowTheme = LocalBaseUITheme.current.listRowTile - val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f + modifier: Modifier = Modifier) { val artworkInteractionSource = remember { MutableInteractionSource() } val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() val rowInteractionSource = remember { MutableInteractionSource() } val isRowHovered by rowInteractionSource.collectIsHoveredAsState() val rowBackgroundColor = when { - isSelected -> rowTheme.background.selected - isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered - isCurrentTrack -> rowTheme.background.focused - trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f)) - else -> rowTheme.background.normal + isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f) + isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer + isCurrentTrack -> AppTheme.colors.surfaceVariant + trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f) + else -> Color.Transparent } - val highlightColor = Color.White.copy( - alpha = if (isLight) 0.9f else 0.06f - ) - Row( - modifier = modifier - .fillMaxWidth() - .hoverable(rowInteractionSource) - .combinedClickable( - onClick = onTrackClick, - onLongClick = onLongClick, - ) - .background(rowBackgroundColor) - .then( - if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) { - Modifier.highlight(highlightColor) - } else { - Modifier - } - ) - .clip(MaterialTheme.shapes.small) - .padding(horizontal = 8.dp, vertical = 6.dp) - .padding(end = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (isSelectionMode) { - CheckBox( - state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - onClick = { onSelectionToggle(!isSelected) }, - modifier = Modifier.shimmerApply() - ) - } else if (showIndex) { - Box( - modifier = Modifier.width(30.dp), - contentAlignment = Alignment.Center, - ) { - AnimatedContent( - targetState = isRowHovered, - transitionSpec = { - fadeIn() togetherWith fadeOut() - }, - label = "index-checkbox", - ) { hovered -> - if (hovered) { - CheckBox( - state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - onClick = { onSelectionToggle(!isSelected) }, - modifier = Modifier.shimmerApply() - ) - } else { - TextWithShimmer( - text = index.toString(), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - Box( - modifier = Modifier - .size(44.dp) - .clip(MaterialTheme.shapes.small) - .hoverable(interactionSource = artworkInteractionSource), - contentAlignment = Alignment.Center, - ) { - - val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url - val platformContext = LocalPlatformContext.current - val imageRequest = remember(imageUrl) { - ImageRequest.Builder(platformContext) - .data(imageUrl) - .size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size. - .crossfade(false) // Crucial for scroll performance - .build() - } - - AsyncImage( - model = imageRequest, - contentDescription = track.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().shimmerApply(), - ) - - if (isArtworkHovered || isCurrentTrack) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.10f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play", - tint = Color.White, - modifier = Modifier.size(18.dp).shimmerApply(), - ) - } - } - } - - Column(modifier = Modifier.weight(1.2f)) { + MillenniumListTile( + modifier = modifier, + selected = isSelected || isCurrentTrack, + onClick = onTrackClick, + title = { TextWithShimmer( text = track.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) + }, + subtitle = { ArtistChips( artists = track.artists, onArtistClick = onArtistClick, - onArtistsOverflowClick = onArtistsOverflowClick, - ) - } - - if (showAlbum && track.album != null) { - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - val colorScheme = MaterialTheme.colorScheme - + onArtistsOverflowClick = onArtistsOverflowClick) + }, + leading = { Row( - modifier = Modifier.weight(1f) - ) { - TextWithShimmer( - text = track.album!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .hoverable(interactionSource) - .clickable( - indication = null, - interactionSource = interactionSource - ) { onAlbumClick(track.album!!) } - // Hide ripple and show underline on hover/click - .then( - if (isHovered || isPressed) { - Modifier.drawWithCache { - val underlineHeight = 1.dp.toPx() - onDrawWithContent { - drawContent() - drawLine( - color = colorScheme.primary, - start = Offset(0f, size.height - underlineHeight), - end = Offset(size.width, size.height - underlineHeight), - strokeWidth = underlineHeight, - ) - } - } + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (isSelectionMode) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) + } else if (showIndex) { + Box( + modifier = Modifier.width(30.dp), + contentAlignment = Alignment.Center) { + AnimatedContent( + targetState = isRowHovered, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "index-checkbox") { hovered -> + if (hovered) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) } else { - Modifier + TextWithShimmer( + text = index.toString(), + style = AppTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = AppTheme.colors.onSurfaceVariant) } - ) - ) - } - } + } + } + } - TextWithShimmer( - text = track.durationMs.toDurationString(), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.End, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(56.dp), - ) + Box( + modifier = Modifier + .size(44.dp) + .clip(AppTheme.shapes.small) + .hoverable(interactionSource = artworkInteractionSource), + contentAlignment = Alignment.Center) { + val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url + val platformContext = LocalPlatformContext.current + val imageRequest = remember(imageUrl) { + ImageRequest.Builder(platformContext) + .data(imageUrl) + .size(128) + .crossfade(false) + .build() + } - if (useDropdownForOptions) { - TrackOptions( - track = track, - state = trackOptionsState, - onAction = onTrackOptionsAction, - onAlbumClick = { track.album?.let { onAlbumClick(it) } }, - isInJam = isInJam, - isJamGuest = isJamGuest, - ) - } else { - GhostIconButton(onClick = onShowOptionsClick) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Track options", - ) + AsyncImage( + model = imageRequest, + contentDescription = track.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().shimmerApply()) + + if (isArtworkHovered || isCurrentTrack) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(18.dp).shimmerApply()) + } + } + } } - } - } + }, + trailing = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (showAlbum && track.album != null) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val colorScheme = AppTheme.colors + + TextWithShimmer( + text = track.album!!.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(max = 220.dp) + .hoverable(interactionSource) + .clickable( + indication = null, + interactionSource = interactionSource) { onAlbumClick(track.album!!) } + .then( + if (isHovered || isPressed) { + Modifier.drawWithCache { + val underlineHeight = 1.dp.toPx() + onDrawWithContent { + drawContent() + drawLine( + color = colorScheme.primary, + start = Offset(0f, size.height - underlineHeight), + end = Offset(size.width, size.height - underlineHeight), + strokeWidth = underlineHeight) + } + } + } else { + Modifier + })) + } + + TextWithShimmer( + text = track.durationMs.toDurationString(), + style = AppTheme.typography.bodySmall, + textAlign = TextAlign.End, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.width(56.dp)) + + if (useDropdownForOptions) { + TrackOptions( + track = track, + state = trackOptionsState, + onAction = onTrackOptionsAction, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest) + } else { + MillenniumIconButton( + onClick = onShowOptionsClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = "Track options") + } + } + } + }) } @Composable private fun ArtistChips( artists: List, onArtistClick: (MetadataArtist.Basic) -> Unit, - onArtistsOverflowClick: () -> Unit, -) { + onArtistsOverflowClick: () -> Unit) { val visibleArtists = artists.take(2) Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { visibleArtists.forEachIndexed { index, artist -> Row( - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() val isPressed by interactionSource.collectIsPressedAsState() TextWithShimmer( text = artist.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, @@ -827,13 +748,12 @@ private fun ArtistChips( indication = null, interactionSource = interactionSource ) { onArtistClick(artist) } - , - ) + ) if (index < visibleArtists.size - 1) Text( ",", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodySmall ) } } @@ -842,12 +762,11 @@ private fun ArtistChips( if (remaining > 0) { TextWithShimmer( text = "+$remaining more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable(onClick = onArtistsOverflowClick), - ) + modifier = Modifier.clickable(onClick = onArtistsOverflowClick)) } } } @@ -857,8 +776,7 @@ private fun ShimmerTrackListRow( index: Int, showIndex: Boolean, showAlbum: Boolean, - useDropdownForOptions: Boolean, -) { + useDropdownForOptions: Boolean) { val dummyTrack = remember(index) { dummyTrackForShimmer(index) } SkeletonTree(true) { TrackListRow( @@ -881,8 +799,7 @@ private fun ShimmerTrackListRow( onShowOptionsClick = {}, onArtistClick = {}, onAlbumClick = {}, - onArtistsOverflowClick = {}, - ) + onArtistsOverflowClick = {}) } } @@ -891,8 +808,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack { id = "shimmer_artist_$index", name = "Artist Name", thumbnails = emptyList(), - externalUri = null, - ) + externalUri = null) return MetadataTrack( id = "shimmer_track_$index", title = "Track Title Here", @@ -913,29 +829,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack { thumbnails = emptyList(), albumType = MetadataAlbumType.Album, artists = listOf(artist), - externalUri = null, - ), + externalUri = null), thumbnails = null, explicit = false, popularity = 0, isrcCode = null, - externalUri = null, - ) + externalUri = null) } @Composable private fun TrackListFeedbackRow( message: String, - isError: Boolean = false, -) { + isError: Boolean = false) { TextWithShimmer( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - ) + .padding(horizontal = 8.dp, vertical = 12.dp)) } private fun Long.toDurationString(): String { @@ -951,21 +863,17 @@ fun previewTracks(): List { id = "artist_1", name = "Aria Vale", thumbnails = emptyList(), - externalUri = null, - ), + externalUri = null), MetadataArtist.Basic( id = "artist_2", name = "Neon Harbor", thumbnails = emptyList(), - externalUri = null, - ), + externalUri = null), MetadataArtist.Basic( id = "artist_3", name = "Echo Drift", thumbnails = emptyList(), - externalUri = null, - ), - ) + externalUri = null)) val album = MetadataAlbum.Detailed( releaseDate = null, @@ -978,13 +886,11 @@ fun previewTracks(): List { Thumbnail( url = "https://picsum.photos/120", width = 120, - height = 120, - ) + height = 120) ), albumType = MetadataAlbumType.Album, artists = artists.take(2), - externalUri = null, - ) + externalUri = null) return listOf( MetadataTrack( @@ -999,8 +905,7 @@ fun previewTracks(): List { explicit = false, popularity = 92, isrcCode = null, - externalUri = null, - ), + externalUri = null), MetadataTrack( id = "track_2", title = "Static Horizon", @@ -1013,8 +918,7 @@ fun previewTracks(): List { popularity = 80, isrcCode = null, externalUri = null, - thumbnails = null, - ), + thumbnails = null), MetadataTrack( id = "track_3", title = "Last Train Echo", @@ -1027,19 +931,16 @@ fun previewTracks(): List { popularity = 77, isrcCode = null, externalUri = null, - thumbnails = null, - ), - ) + thumbnails = null)) } @Composable @Preview private fun TrackListPreview() { - BaseScaffold { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { TrackList( tracks = previewTracks(), hasMore = true, - isLoadingNextPage = true, - ) + isLoadingNextPage = true) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index bccc4fb2..0f4653b8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -24,12 +31,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore @@ -72,8 +76,7 @@ data class TrackOptionsState( val isInQueue: Boolean = false, val isCurrentlyPlaying: Boolean = false, val isFavorite: Boolean = false, - val isBlacklisted: Boolean = false, -) + val isBlacklisted: Boolean = false) data class TrackOptionsContext( val currentTrackId: String? = null, @@ -81,15 +84,13 @@ data class TrackOptionsContext( val savedTrackIds: Set = emptySet(), val blacklistedTrackIds: Set = emptySet(), val blacklistedArtistIds: Set = emptySet(), - val forceFavorite: Boolean = false, -) { + val forceFavorite: Boolean = false) { fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState( isInQueue = track.id in queueTrackIds, isCurrentlyPlaying = track.id == currentTrackId, isFavorite = forceFavorite || track.id in savedTrackIds, isBlacklisted = track.id in blacklistedTrackIds || - track.artists.any { it.id in blacklistedArtistIds }, - ) + track.artists.any { it.id in blacklistedArtistIds }) } @Composable @@ -100,8 +101,7 @@ fun TrackOptions( onAlbumClick: () -> Unit, modifier: Modifier = Modifier, isInJam: Boolean = false, - isJamGuest: Boolean = false, -) { + isJamGuest: Boolean = false) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( track = track, @@ -109,11 +109,12 @@ fun TrackOptions( onAction = onAction, onAlbumClick = onAlbumClick, isInJam = isInJam, - isJamGuest = isJamGuest, - ), + isJamGuest = isJamGuest), trigger = { onClick -> - GhostIconButton(onClick = onClick) { - Icon( + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, contentDescription = "Track options", modifier = Modifier.shimmerApply() @@ -122,11 +123,9 @@ fun TrackOptions( }, headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, header = { TrackOptionsSheetHeader(track = track) }, - modifier = modifier, - ) + modifier = modifier) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrackOptionsBottomSheet( track: MetadataTrack, @@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, - isJamGuest: Boolean = false, -) { - ModalBottomSheet(onDismissRequest = onDismiss) { + isJamGuest: Boolean = false) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { Column(modifier = Modifier.fillMaxWidth()) { TrackOptionsSheetHeader(track = track) @@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { buildTrackMenuItems( track = track, state = state, @@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet( onDismiss() }, isInJam = isInJam, - isJamGuest = isJamGuest, - ).forEach { item -> + isJamGuest = isJamGuest).forEach { item -> Row( modifier = Modifier .fillMaxWidth() .clickable( enabled = item.enabled, - onClick = item.onClick, - ) + onClick = item.onClick) .padding(horizontal = 12.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { item.icon?.let { icon -> - Icon( + MillenniumIcon( imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) } Text( text = item.label, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, color = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) } } } @@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) { .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { AsyncImage( model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) - .clip(MaterialTheme.shapes.small), - ) + .clip(AppTheme.shapes.small)) Column(modifier = Modifier.weight(1f)) { Text( text = track.title, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = track.artists.joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) track.album?.let { album -> Text( text = album.title, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } @@ -251,25 +244,20 @@ private fun buildTrackMenuItems( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, - isJamGuest: Boolean = false, -): List = buildList { + isJamGuest: Boolean = false): List = buildList { if (isInJam) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = "Add to Jam", - onClick = { onAction(TrackOptionsAction.AddToJam) }, - ), - ) + onClick = { onAction(TrackOptionsAction.AddToJam) })) } add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicCircle, label = "Start radio", - onClick = { onAction(TrackOptionsAction.StartRadio) }, - ), - ) + onClick = { onAction(TrackOptionsAction.StartRadio) })) // A guest's queue is the shared jam queue — mutating it locally is not // allowed, so queue actions are replaced by "Add to Jam". @@ -279,17 +267,13 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, label = "Play next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) + onClick = { onAction(TrackOptionsAction.PlayNext) })) } else if (state.isInQueue && !state.isCurrentlyPlaying) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, label = "Move to next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) + onClick = { onAction(TrackOptionsAction.PlayNext) })) } if (!state.isInQueue) { @@ -297,17 +281,13 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = "Add to queue", - onClick = { onAction(TrackOptionsAction.AddToQueue) }, - ), - ) + onClick = { onAction(TrackOptionsAction.AddToQueue) })) } else { add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicSquareRemove, label = "Remove from queue", - onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, - ), - ) + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) })) } } @@ -315,42 +295,32 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, label = if (state.isFavorite) "Remove from favorites" else "Save as favorite", - onClick = { onAction(TrackOptionsAction.ToggleFavorite) }, - ), - ) + onClick = { onAction(TrackOptionsAction.ToggleFavorite) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicPlaylist, label = "Add to playlist", - onClick = { onAction(TrackOptionsAction.AddToPlaylist) }, - ), - ) + onClick = { onAction(TrackOptionsAction.AddToPlaylist) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, label = "Download", - onClick = { onAction(TrackOptionsAction.Download) }, - ), - ) + onClick = { onAction(TrackOptionsAction.Download) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicSquareRemoveFilled, label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", - onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }, - ), - ) + onClick = { onAction(TrackOptionsAction.ToggleBlacklist) })) if (!track.externalUri.isNullOrBlank()) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxShare, label = "Share", - onClick = { onAction(TrackOptionsAction.Share) }, - ), - ) + onClick = { onAction(TrackOptionsAction.Share) })) } if (track.album != null) { @@ -358,8 +328,6 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = Iconsax.IconsaxCd, label = "Go to album", - onClick = onAlbumClick, - ), - ) + onClick = onAlbumClick)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt index 1b4b9b13..868ec005 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt @@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard @Composable fun UserCard( user: MetadataUser, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { AvatarCard( title = user.displayName ?: user.username, subtitle = "User", @@ -53,7 +52,6 @@ fun UserCard( onClick = { // TODO: Route to user details screen. }, - modifier = modifier, - ) + modifier = modifier) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt index 2522f276..d677bdd3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt @@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop @Composable expect fun VerticalScrollbar( listState: LazyListState, - modifier: Modifier = Modifier, -) + modifier: Modifier = Modifier) fun Modifier.dragScrollable(rowState: LazyListState): Modifier = if (getPlatform().isDesktop()) pointerInput(rowState) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt index 940b6b3d..ff08d418 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt @@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,10 +58,9 @@ fun AvatarCard( imageURL: String? = null, onClick: () -> Unit = {}, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false, -) { + isBlacklisted: Boolean = false) { val backgroundColor = if (isBlacklisted) { - MaterialTheme.colorScheme.error.copy(alpha = 0.1f) + AppTheme.colors.error.copy(alpha = 0.1f) } else { Color.Transparent } @@ -71,20 +70,17 @@ fun AvatarCard( .clip(RoundedCornerShape(8.dp)) .background(backgroundColor) .clickable(onClick = { onClick.invoke() }, enabled = true) - .width(160.dp), - ) { + .width(160.dp)) { Column( modifier = Modifier .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier .fillMaxWidth() .clip(CircleShape) .padding(12.dp), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { AsyncImage( model = imageURL, contentDescription = title, @@ -93,27 +89,24 @@ fun AvatarCard( .aspectRatio(1f) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } Column( modifier = Modifier.fillMaxWidth().padding(12.dp) ) { TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) subtitle?.let { TextWithShimmer( text = subtitle, - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt index 89b9458c..74ccc375 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component.cards + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn @@ -36,9 +40,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -50,10 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone @@ -75,8 +74,7 @@ fun PlayableCard( onPlay: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, - sharedElementKey: String? = null, -) { + sharedElementKey: String? = null) { val interactionSource = remember { MutableInteractionSource() } val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current @@ -86,8 +84,7 @@ fun PlayableCard( .clip(RoundedCornerShape(8.dp)) .clickable(onClick = { onClick?.invoke() }, enabled = true) .width(160.dp) - .hoverable(interactionSource = interactionSource), - ) { + .hoverable(interactionSource = interactionSource)) { Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { AsyncImage( @@ -100,10 +97,8 @@ fun PlayableCard( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ), - contentScale = ContentScale.Crop, - ) + animatedVisibilityScope = animatedVisibilityScope), + contentScale = ContentScale.Crop) if (onPlay != null || onAddToQueue != null) Box( @@ -114,46 +109,35 @@ fun PlayableCard( this@Column.AnimatedVisibility( visible = isHovered, enter = fadeIn(), - exit = fadeOut(), - ) { + exit = fadeOut()) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { onAddToQueue?.let { - SecondaryIconButton( + MillenniumIconButton( onClick = onAddToQueue, - modifier = Modifier.size(30.dp), - theme = LocalBaseUITheme.current.iconButtons.secondary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue( - CircleShape - ) - ) + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue", - ) + contentDescription = "Add to queue") } } Spacer(modifier = Modifier.height(8.dp)) onPlay?.let { - PrimaryIconButton( + MillenniumIconButton( onClick = onPlay, - modifier = Modifier.size(30.dp), - theme = LocalBaseUITheme.current.iconButtons.secondary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue( - CircleShape - ) - ) + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Primary ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) { Iconsax.IconsaxPause } else Iconsax.IconsaxPlay, - contentDescription = "Play", - ) + contentDescription = "Play") } } } @@ -165,19 +149,17 @@ fun PlayableCard( ) { TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) subtitle?.let { TextWithShimmer( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } @@ -185,11 +167,10 @@ fun PlayableCard( } } - @PreviewLightDark @Composable private fun PlayableCardPreview() { - BaseScaffold { + Box { PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt index 84569b6e..1eafe443 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt @@ -45,8 +45,7 @@ fun Coverflow( modifier: Modifier = Modifier, state: CoverflowState = rememberCoverflowState(), params: CoverflowParams = CoverflowParams(), - content: CoverflowScope.() -> Unit, -) { + content: CoverflowScope.() -> Unit) { var size by remember { mutableStateOf(IntSize.Zero) } var geometry by remember { mutableStateOf(null) } @@ -55,8 +54,7 @@ fun Coverflow( val g = geometry ?: return@LaunchedEffect state.lazyListState.scrollToItem( state.selectedIndex + 1, - -g.spacerWidth, - ) + -g.spacerWidth) state.initialScrollDone = true } @@ -69,8 +67,7 @@ fun Coverflow( }, verticalAlignment = Alignment.CenterVertically, state = state.lazyListState, - flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState), - ) { + flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) { if (size != IntSize.Zero) { val g = Geometry(params, size) geometry = g @@ -79,8 +76,7 @@ fun Coverflow( val coverflowScope = CoverflowScopeImpl( geometry = g, lazyListScope = this, - coverflowState = state, - ) + coverflowState = state) item { Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() })) @@ -102,15 +98,13 @@ fun Coverflow( if (state.selectedIndex > 0) { state.scrollToItem(state.selectedIndex - 1) } - }, - ) {} + }) {} Box( modifier = Modifier .fillMaxHeight() .align(Alignment.CenterEnd) .clickable { state.scrollToItem(state.selectedIndex + 1) - }, - ) {} + }) {} } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt index 0ea24cc7..72151f39 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt @@ -25,8 +25,7 @@ import kotlin.math.min @Stable internal class Geometry( internal val params: CoverflowParams = CoverflowParams(), - private val size: IntSize = IntSize.Zero, -) { + private val size: IntSize = IntSize.Zero) { @Stable private val shortEdge get() = min(size.width, size.height) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt index 9eabc429..4ca74a4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt @@ -41,8 +41,7 @@ internal fun CoverflowItem( geometry: Geometry, onClickHandler: () -> Unit = {}, onSelectedHandler: (Boolean) -> Unit, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { var horizontalPosition by remember { mutableStateOf(null) } val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f) @@ -53,8 +52,7 @@ internal fun CoverflowItem( .onGloballyPositioned { coordinates -> horizontalPosition = coordinates.positionInParent().x }, - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (horizontalPosition != null) { onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f)) @@ -65,20 +63,16 @@ internal fun CoverflowItem( rotationY = geometry.rotation(distanceToCenter), translationX = geometry.translationX(distanceToCenter), scaleY = geometry.scale(distanceToCenter), - scaleX = geometry.scale(distanceToCenter), - ), - propagateMinConstraints = true, - ) { + scaleX = geometry.scale(distanceToCenter)), + propagateMinConstraints = true) { val interactionSource = remember { MutableInteractionSource() } if (geometry.params.mirror) { Mirror( modifier = Modifier.clickable( interactionSource = interactionSource, indication = null, - onClick = onClickHandler, - ), - coverSize = geometry.coverSize, - ) { + onClick = onClickHandler), + coverSize = geometry.coverSize) { content() } } else { @@ -86,9 +80,7 @@ internal fun CoverflowItem( modifier = Modifier.clickable( interactionSource = interactionSource, indication = null, - onClick = onClickHandler, - ), - ) { + onClick = onClickHandler)) { content() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt index 6298a886..c2a7bf5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt @@ -26,5 +26,4 @@ data class CoverflowParams( val angle: Float = 45f, val shift: Float = 0.4f, val zoom: Float = 0.8f, - val mirror: Boolean = false, -) + val mirror: Boolean = false) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt index ef4fb578..e534c464 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt @@ -26,37 +26,32 @@ interface CoverflowScope { onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> }, key: ((index: Int) -> Any)? = null, contentType: (index: Int) -> Any? = { null }, - itemContent: @Composable ((item: T) -> Unit), - ) + itemContent: @Composable ((item: T) -> Unit)) fun items( count: Int, onSelectHandler: (index: Int) -> Unit = {}, key: ((index: Int) -> Any)? = null, contentType: (index: Int) -> Any? = { null }, - itemContent: @Composable ((index: Int) -> Unit), - ) + itemContent: @Composable ((index: Int) -> Unit)) } internal class CoverflowScopeImpl( private val geometry: Geometry, private val lazyListScope: LazyListScope, - private val coverflowState: CoverflowState, -) : CoverflowScope { + private val coverflowState: CoverflowState) : CoverflowScope { override fun items( items: List, onSelectHandler: (item: T, index: Int) -> Unit, key: ((index: Int) -> Any)?, contentType: (index: Int) -> Any?, - itemContent: @Composable (item: T) -> Unit, - ) = items( + itemContent: @Composable (item: T) -> Unit) = items( count = items.size, onSelectHandler = { onSelectHandler(items[it], it) }, key = key, - contentType = contentType, - ) { + contentType = contentType) { itemContent(items[it]) } @@ -65,12 +60,10 @@ internal class CoverflowScopeImpl( onSelectHandler: (index: Int) -> Unit, key: ((index: Int) -> Any)?, contentType: (index: Int) -> Any?, - itemContent: @Composable (index: Int) -> Unit, - ) = lazyListScope.items( + itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items( count, key, - contentType, - ) { + contentType) { CoverflowItem( onClickHandler = { coverflowState.scrollToItem(it) }, onSelectedHandler = { isSelected: Boolean -> @@ -78,16 +71,14 @@ internal class CoverflowScopeImpl( onSelectHandler(it) } }, - geometry = geometry, - ) { + geometry = geometry) { itemContent(it) } } private fun selectHandler( index: Int, - isSelected: Boolean, - ): Boolean { + isSelected: Boolean): Boolean { if (!isSelected && index == coverflowState.selectedIndex) { coverflowState.selectedIndex = -1 } else if (isSelected && index != coverflowState.selectedIndex) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt index 99a16d77..5e716f59 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt @@ -28,19 +28,16 @@ import kotlinx.coroutines.launch @Composable fun rememberCoverflowState( initialIndex: Int = 0, - onSelectHandler: (Int) -> Unit = {}, -): CoverflowState { + onSelectHandler: (Int) -> Unit = {}): CoverflowState { val lazyListState: LazyListState = rememberLazyListState( - initialFirstVisibleItemIndex = initialIndex + 1, - ) + initialFirstVisibleItemIndex = initialIndex + 1) val coroutineScope: CoroutineScope = rememberCoroutineScope() return remember { CoverflowState( lazyListState, coroutineScope, onSelectHandler, - initialIndex, - ) + initialIndex) } } @@ -48,8 +45,7 @@ class CoverflowState internal constructor( internal val lazyListState: LazyListState, private val coroutineScope: CoroutineScope, private val onSelectHandler: (Int) -> Unit = {}, - initialIndex: Int = 0, -) { + initialIndex: Int = 0) { internal var geometry: Geometry? = null internal var initialScrollDone: Boolean = false @@ -65,8 +61,7 @@ class CoverflowState internal constructor( coroutineScope.launch { lazyListState.animateScrollToItem( index + 1, - -geometryCopy.spacerWidth, - ) + -geometryCopy.spacerWidth) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt index 3ec6fa81..008f788b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt @@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times internal fun Mirror( modifier: Modifier = Modifier, coverSize: Float, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() } Column( modifier = modifier .padding(top = coverSizeDp) .fillMaxWidth() - .requiredHeight(2 * coverSizeDp), - ) { + .requiredHeight(2 * coverSizeDp)) { Box( propagateMinConstraints = true, modifier = Modifier .weight(1f) .fillMaxWidth() - .height(coverSizeDp), - ) { + .height(coverSizeDp)) { content() } Box( @@ -75,34 +72,27 @@ internal fun Mirror( .height(coverSizeDp) .graphicsLayer( compositingStrategy = CompositingStrategy.Offscreen, - rotationX = 180f, - ) + rotationX = 180f) .clip(HalfSizeShape) .drawWithContent { val colors = listOf( Color.Transparent, Color.Transparent, - Color.Black, - ) + Color.Black) drawContent() drawRect( topLeft = Offset( 0f, - this.size.height / 2, - ), + this.size.height / 2), size = Size( this.size.width, - this.size.height / 2, - ), + this.size.height / 2), brush = Brush.verticalGradient(colors), - blendMode = BlendMode.DstIn, - ) + blendMode = BlendMode.DstIn) } .blur( radiusX = 1.dp, - radiusY = 3.dp, - ), - ) { + radiusY = 3.dp)) { content() } } @@ -112,17 +102,12 @@ private object HalfSizeShape : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, - density: Density, - ): Outline = Outline.Rectangle( + density: Density): Outline = Outline.Rectangle( Rect( Offset( 0f, - size.height / 2, - ), + size.height / 2), Size( size.width, - size.height, - ), - ), - ) + size.height))) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt index a5040409..9cbd742f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt @@ -17,10 +17,11 @@ package dev.krtirtho.spotube.core.ui.misc +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.TextAutoSize -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -38,7 +39,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit -import com.eygraber.compose.placeholder.material3.placeholder import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer @@ -84,11 +84,7 @@ fun Modifier.shimmerApply( ): Modifier = composed { val isTreeLoading = LocalSkeletonLoading.current - this.placeholder( - visible = isTreeLoading, - color = color, - shape = null, - ) + if (isTreeLoading) this.background(color) else this } @Composable @@ -110,23 +106,20 @@ fun TextWithShimmer( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, onTextLayout: ((TextLayoutResult) -> Unit)? = null, - style: TextStyle = LocalTextStyle.current, -) { + style: TextStyle = LocalTextStyle.current) { Text( text = text, color = color, modifier = Modifier.shimmerApply().then(modifier), autoSize = autoSize, fontSize = fontSize, - fontStyle = fontStyle, fontWeight = fontWeight, fontFamily = fontFamily, letterSpacing = letterSpacing, textDecoration = textDecoration, - textAlign = textAlign, + textAlign = textAlign ?: TextAlign.Start, lineHeight = lineHeight, overflow = overflow, - softWrap = softWrap, maxLines = maxLines, minLines = minLines, onTextLayout = onTextLayout, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index 633d1f96..b58b713f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -18,93 +18,188 @@ package dev.krtirtho.spotube.core.ui.theming import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember -import com.materialkolor.rememberDynamicColorScheme -import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme -import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.millennium.theme.MillenniumColors +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig + +/** + * Semantic color roles used across the app, resolved from the active Millennium palette. + * + * This is not a second palette: every role is derived from [MillenniumColors], so the + * whole app follows the selected Millennium accent family and light/dark appearance. + */ +@Immutable +data class AppColors( + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val tertiaryContainer: Color, + val onTertiaryContainer: Color, + val background: Color, + val onBackground: Color, + val surface: Color, + val onSurface: Color, + val surfaceVariant: Color, + val onSurfaceVariant: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val inverseSurface: Color, + val inverseOnSurface: Color, + val outline: Color, + val outlineVariant: Color, + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, + val scrim: Color) + +/** Maps Millennium's semantic palette onto the app's role names. */ +fun appColorsFrom(m: MillenniumColors): AppColors = AppColors( + primary = m.accent, + onPrimary = m.onAccent, + primaryContainer = m.raised, + onPrimaryContainer = m.text, + secondary = m.accent, + onSecondary = m.onAccent, + secondaryContainer = m.raised, + onSecondaryContainer = m.text, + tertiaryContainer = m.raised, + onTertiaryContainer = m.text, + background = m.background, + onBackground = m.text, + surface = m.surface, + onSurface = m.text, + surfaceVariant = m.inset, + onSurfaceVariant = m.muted, + surfaceContainer = m.surface, + surfaceContainerHigh = m.raised, + surfaceContainerHighest = m.raised, + inverseSurface = m.text, + inverseOnSurface = m.background, + outline = m.border, + outlineVariant = m.border, + error = m.danger, + onError = m.background, + errorContainer = m.danger.copy(alpha = 0.16f), + onErrorContainer = m.danger, + scrim = m.scrim) + +/** Named text styles used by the app, independent of Material typography. */ +@Immutable +data class AppTypography( + val displaySmall: TextStyle, + val headlineLarge: TextStyle, + val headlineMedium: TextStyle, + val headlineSmall: TextStyle, + val titleLarge: TextStyle, + val titleMedium: TextStyle, + val titleSmall: TextStyle, + val bodyLarge: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + val labelLarge: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle) + +private fun style( + size: Int, + lineHeight: Int, + weight: FontWeight = FontWeight.Normal): TextStyle = TextStyle( + fontFamily = FontFamily.SansSerif, + fontSize = size.sp, + lineHeight = lineHeight.sp, + fontWeight = weight) + +val AppDefaultTypography = AppTypography( + displaySmall = style(36, 44), + headlineLarge = style(32, 40), + headlineMedium = style(28, 36), + headlineSmall = style(24, 32), + titleLarge = style(22, 28, FontWeight.Medium), + titleMedium = style(16, 24, FontWeight.Medium), + titleSmall = style(14, 20, FontWeight.Medium), + bodyLarge = style(16, 24), + bodyMedium = style(14, 20), + bodySmall = style(12, 16), + labelLarge = style(14, 20, FontWeight.Medium), + labelMedium = style(12, 16, FontWeight.Medium), + labelSmall = style(11, 16, FontWeight.Medium)) + +/** Corner shapes used by the app. */ +@Immutable +data class AppShapes( + val small: Shape, + val medium: Shape, + val large: Shape) + +val AppDefaultShapes = AppShapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp)) + +val LocalAppColors = staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) } +val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography } +val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes } + +/** Reads the active app theme tokens, all backed by Millennium. */ +object AppTheme { + val colors: AppColors @Composable get() = LocalAppColors.current + val typography: AppTypography @Composable get() = LocalAppTypography.current + val shapes: AppShapes @Composable get() = LocalAppShapes.current +} /** * The app's single source of truth for colour. * - * The selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a - * [BaseUIColorScheme], and every Material role is then overridden from it. That way the - * whole app — Material components included — renders in the chosen surface theme's colours - * and accent, instead of Material's dynamic palette drifting away from the base UI. + * Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark + * [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and + * [AppShapes] tokens from the active Millennium theme. */ @Composable fun SpotubeTheme( settings: UserSettings, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val isDarkTheme = when (settings.theme) { Theme.LIGHT -> false Theme.DARK -> true Theme.SYSTEM -> isSystemInDarkTheme() } - val isLight = !isDarkTheme + val palette: MillenniumPalette = settings.palette - val accent = if (isDarkTheme) { - settings.accentColor.toDarkColor() - } else { - settings.accentColor.toLightColor() - } - - val baseColors = remember(settings.surfaceTheme, isLight, accent) { - baseUIColorScheme(settings.surfaceTheme, isLight, accent) - } - - val dynamicScheme = rememberDynamicColorScheme( - seedColor = accent, - isDark = isDarkTheme, - ) - - MaterialTheme( - colorScheme = dynamicScheme.mergeWithBaseColors(baseColors), - ) { - Surface(content = content) + MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) { + val colors = appColorsFrom(MillenniumTheme.colors) + CompositionLocalProvider( + LocalAppColors provides colors, + LocalAppTypography provides AppDefaultTypography, + LocalAppShapes provides AppDefaultShapes, + content = content) } } -/** - * Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the - * base UI palette, so Material components share the exact same colours as the base - * components and the surface theme's primary/secondary backgrounds. - */ -private fun ColorScheme.mergeWithBaseColors(base: BaseUIColorScheme): ColorScheme = copy( - primary = base.accentPrimary, - onPrimary = base.onAccentPrimary, - primaryContainer = base.primaryContainer, - onPrimaryContainer = base.onPrimaryContainer, - inversePrimary = base.actionInverse, - secondary = base.accentSecondary, - onSecondary = base.onAccentSecondary, - secondaryContainer = base.secondaryContainer, - onSecondaryContainer = base.onSecondaryContainer, - background = base.surface, - onBackground = base.onBackground, - surface = base.surface, - onSurface = base.onSurface, - surfaceVariant = base.surfaceVariant, - onSurfaceVariant = base.onSurfaceVariant, - surfaceTint = base.accentPrimary, - surfaceBright = base.surfaceContainerHigh, - surfaceDim = base.surfaceContainerHighest, - surfaceContainerLowest = base.surface, - surfaceContainerLow = base.surfaceContainer, - surfaceContainer = base.surfaceContainer, - surfaceContainerHigh = base.surfaceContainerHigh, - surfaceContainerHighest = base.surfaceContainerHighest, - inverseSurface = base.backgroundInverse, - inverseOnSurface = base.onBackgroundInverse, - outline = base.outline, - outlineVariant = base.outlineVariant, - error = base.error, - onError = base.onError, - scrim = base.scrim, -) +/** Whether the active Millennium appearance is light. */ +@Composable +fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index 7d3373cd..d166f722 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -1,221 +1,230 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.webview - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.ChevronLeft -import compose.icons.feathericons.ChevronRight -import compose.icons.feathericons.X -import dev.krtirtho.spotube.core.tools.user_agents.UserAgents -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.nucleusframework.webview.jsbridge.IJsMessageHandler -import dev.nucleusframework.webview.jsbridge.JsMessage -import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge -import dev.nucleusframework.webview.web.WebView -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import dev.nucleusframework.webview.web.rememberWebViewNavigator - -class PostMessageHandler( - private val onMessageReceived: (String) -> Unit = {} -) : IJsMessageHandler { - override fun methodName(): String { - return "sendMessage" - } - - override fun handle( - message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit - ) { - onMessageReceived(message.params) - callback(message.params) - } -} - -@Composable -fun PlatformWebViewScreen(webViewController: WebViewController) { - if (webViewController.getContent() == null) { - // This should never happen, but just in case - Text("No URL to load") - return - } - - val state = remember { - WebViewState( - webViewController.getWebContent( - additionalHttpHeaders = mapOf( - "User-Agent" to UserAgents.random() - ) - ) - ) - }.apply { - this.content = webViewController.getWebContent() - platformWebviewConfig(this, webViewController.currentPluginId) - } - - val navigator = rememberWebViewNavigator() - val webViewBridge = rememberWebViewJsBridge(navigator) - - val bridgeBootstrapScript = remember { - """ - (function() { - if (typeof window.sendMessage !== "function") { - window.sendMessage = function(message) { - if (typeof message !== "string") { - throw new TypeError("[window.sendMessage] Message must be a string"); - } - window.kmpJsBridge.callNative("sendMessage", message); - }; - } - - if (!window.bridgeReady) { - const event = new CustomEvent("onBridgeReady"); - window.dispatchEvent(event); - window.bridgeReady = true; - } - })(); - """.trimIndent() - } - - LaunchedEffect(state) { - snapshotFlow { state.lastLoadedUrl }.collect { url -> - if (url != null) { - webViewController.emitUrlChange(url) - navigator.evaluateJavaScript(bridgeBootstrapScript) - webViewController.emitWebViewCreated() - } - } - } - - LaunchedEffect(state.cookieManager, navigator) { - webViewController.setCookieManager(cookieManager = state.cookieManager) - webViewController.webViewNavigator = navigator - } - - LaunchedEffect(webViewBridge) { - webViewBridge.register(PostMessageHandler { message -> - webViewController.emitPostMessage(message) - }) - } - - DisposableEffect(Unit) { - onDispose { - webViewController.dispose() - } - } - - BaseScaffold( - contentWindowInsets = WindowInsets.statusBars, - topBar = { - ApplicationMainBar( - title = { - Row( - modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - modifier = Modifier.height(56.dp) - ) { - IconButton( - onClick = { - navigator.navigateBack() - }, enabled = navigator.canGoBack - ) { - Icon( - FeatherIcons.ChevronLeft, - contentDescription = "Go back to browser history" - ) - } - IconButton( - onClick = { - navigator.navigateForward() - }, enabled = navigator.canGoForward - ) { - Icon( - FeatherIcons.ChevronRight, - contentDescription = "Go forward to browser history" - ) - } - } - Surface( - modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp), - shape = RoundedCornerShape(18.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)) - ) { - BasicTextField( - value = state.lastLoadedUrl ?: "", - onValueChange = {}, // Read-only - readOnly = true, - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start - ), - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) - .wrapContentHeight(Alignment.CenterVertically) - ) - } - } - } - ) - }) - { innerPadding -> - WebView( - state = state, - modifier = Modifier.padding(innerPadding).fillMaxSize(), - navigator = navigator, - webViewJsBridge = webViewBridge, - onCreated = { webView -> - navigator.evaluateJavaScript(bridgeBootstrapScript) - }, - ) - } +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.webview + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.ui.draw.clip + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronLeft +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.X +import dev.krtirtho.spotube.core.tools.user_agents.UserAgents +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.rememberWebViewNavigator + +class PostMessageHandler( + private val onMessageReceived: (String) -> Unit = {} +) : IJsMessageHandler { + override fun methodName(): String { + return "sendMessage" + } + + override fun handle( + message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit + ) { + onMessageReceived(message.params) + callback(message.params) + } +} + +@Composable +fun PlatformWebViewScreen(webViewController: WebViewController) { + if (webViewController.getContent() == null) { + // This should never happen, but just in case + Text("No URL to load") + return + } + + val state = remember { + WebViewState( + webViewController.getWebContent( + additionalHttpHeaders = mapOf( + "User-Agent" to UserAgents.random() + ) + ) + ) + }.apply { + this.content = webViewController.getWebContent() + platformWebviewConfig(this, webViewController.currentPluginId) + } + + val navigator = rememberWebViewNavigator() + val webViewBridge = rememberWebViewJsBridge(navigator) + + val bridgeBootstrapScript = remember { + """ + (function() { + if (typeof window.sendMessage !== "function") { + window.sendMessage = function(message) { + if (typeof message !== "string") { + throw new TypeError("[window.sendMessage] Message must be a string"); + } + window.kmpJsBridge.callNative("sendMessage", message); + }; + } + + if (!window.bridgeReady) { + const event = new CustomEvent("onBridgeReady"); + window.dispatchEvent(event); + window.bridgeReady = true; + } + })(); + """.trimIndent() + } + + LaunchedEffect(state) { + snapshotFlow { state.lastLoadedUrl }.collect { url -> + if (url != null) { + webViewController.emitUrlChange(url) + navigator.evaluateJavaScript(bridgeBootstrapScript) + webViewController.emitWebViewCreated() + } + } + } + + LaunchedEffect(state.cookieManager, navigator) { + webViewController.setCookieManager(cookieManager = state.cookieManager) + webViewController.webViewNavigator = navigator + } + + LaunchedEffect(webViewBridge) { + webViewBridge.register(PostMessageHandler { message -> + webViewController.emitPostMessage(message) + }) + } + + DisposableEffect(Unit) { + onDispose { + webViewController.dispose() + } + } + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Row( + modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.height(56.dp) + ) { + MillenniumIconButton( + onClick = { + navigator.navigateBack() + }, + enabled = navigator.canGoBack, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronLeft, + contentDescription = "Go back to browser history" + ) + } + MillenniumIconButton( + onClick = { + navigator.navigateForward() + }, + enabled = navigator.canGoForward, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = "Go forward to browser history" + ) + } + } + Box( + modifier = Modifier + .weight(1f) + .height(36.dp) + .padding(horizontal = 4.dp) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + .border( + BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)), + RoundedCornerShape(18.dp)), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = state.lastLoadedUrl ?: "", + onValueChange = {}, // Read-only + readOnly = true, + singleLine = true, + textStyle = AppTheme.typography.bodyMedium.copy( + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Start + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) + .wrapContentHeight(Alignment.CenterVertically) + ) + } + } + } + ) + WebView( + state = state, + modifier = Modifier.weight(1f).fillMaxWidth(), + navigator = navigator, + webViewJsBridge = webViewBridge, + onCreated = { webView -> + navigator.evaluateJavaScript(bridgeBootstrapScript) + }) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt index 13829efa..80f5514b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt @@ -38,8 +38,7 @@ import org.koin.core.component.KoinComponent import kotlin.reflect.KClass class BuiltInPluginService( - private val pluginInfo: PluginEntry, -) : PluginService, KoinComponent { + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { override val pluginId: String = pluginInfo.id private val loggedInStateFlow = MutableStateFlow(false) @@ -58,7 +57,6 @@ class BuiltInPluginService( } } - override suspend fun start() { when (pluginInfo) { NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt index ccd966a4..42acea9f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt @@ -1,347 +1,344 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.zipline - -import app.cash.zipline.Zipline -import app.cash.zipline.ZiplineService -import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh -import app.cash.zipline.loader.LoadResult -import app.cash.zipline.loader.ManifestVerifier -import app.cash.zipline.loader.ZiplineLoader -import dev.krtirtho.plugin_interfaces.core.Initializer -import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginCapability -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import io.ktor.http.URLBuilder -import io.ktor.http.decodeURLQueryComponent -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import okio.Path.Companion.toPath -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import kotlin.reflect.KClass - - -/** - * Manages the lifecycle of a Zipline plugin. - * It runs everything on its own dispatcher (different thread) as per Zipline's requirements. - * Anything it provides, must be called within that dispatcher context. - * The [use] function must be used. - * - * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well, - * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in - * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows. - * - * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called. - */ -open class ZiplinePluginService( - val applicationName: String, - private val manifestUrl: String, - private val pluginInfo: PluginEntry, -) : PluginService, KoinComponent { - - override val pluginId: String = pluginInfo.id - - // QuickJS compile() uses deep C-level recursion on the native thread stack. - // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules - // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. - // We use a custom EventListener to increase maxStackSize right after the Zipline - // instance is created, before any modules are loaded. - private val ziplineDispatcher = createZiplineDispatcher() - - private fun trace(event: String) { - logger.d { "[$applicationName] $event" } - } - - private val logger by injectLogger() - private val webViewController: WebViewController by inject() - - private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) - private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> - logger.e(throwable) { "Zipline Engine Error" } - } - private val lifecycleMutex = Mutex() - private var ziplineLoader: ZiplineLoader - private var ziplineInstance: Zipline? = null - private val serviceRegistry = mutableMapOf, ZiplineService>() - - init { - val manifestPath = URLBuilder(manifestUrl) - val baseDir = - manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent - ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") - ziplineLoader = ZiplineLoader( - dispatcher = ziplineDispatcher.dispatcher, - manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, - httpClient = FileSystemHTTPClient(baseDir) - ) - } - - private val realHttpClientAPI = RealHttpClientAPI() - private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) - - private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) - private val cryptoAPI = RealCryptoAPI(scope.coroutineContext) - private val systemInformationAPI = RealSystemInformationAPI() - - private val loggedInStateFlow = MutableStateFlow(false) - override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow() - - private fun bindHostServices(zipline: Zipline) { - trace("initializer(): binding host APIs") - logger.d { "[$applicationName] Binding host APIs in initializer" } - try { - // Basic APIs - zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI) - zipline.bind( - SystemInformationAPI_SERVICE_NAME, - systemInformationAPI - ) - - // Conditional APIs based on plugin capabilities - if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { - zipline.bind( - HttpClientAPI_SERVICE_NAME, - realHttpClientAPI - ) - } - if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { - zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) - } - if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { - zipline.bind( - PersistedStorageAPI_SERVICE_NAME, - persistedStorageAPI - ) - } - } catch (e: Exception) { - logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } - throw e - } - } - - private fun consumePluginServices(result: LoadResult.Success) { - trace("start(): loadOnce success") - val apiMap = - buildMap, ZiplineService> { - put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) - - if (PluginAbility.METADATA in pluginInfo.abilities) { - put( - MetadataUserAPI::class, - result.zipline.take( - MetadataUserAPI_SERVICE_NAME - ) - ) - put( - MetadataTrackAPI::class, - result.zipline.take( - MetadataTrackAPI_SERVICE_NAME - ) - ) - put( - MetadataAlbumAPI::class, - result.zipline.take( - MetadataAlbumAPI_SERVICE_NAME - ) - ) - put( - MetadataArtistAPI::class, - result.zipline.take( - MetadataArtistAPI_SERVICE_NAME - ) - ) - put( - MetadataPlaylistAPI::class, - result.zipline.take( - MetadataPlaylistAPI_SERVICE_NAME - ) - ) - put( - MetadataBrowseAPI::class, - result.zipline.take( - MetadataBrowseAPI_SERVICE_NAME - ) - ) - put( - MetadataSearchAPI::class, - result.zipline.take( - MetadataSearchAPI_SERVICE_NAME - ) - ) - } - if (PluginAbility.AUDIO in pluginInfo.abilities) { - put( - AudioAPI::class, - result.zipline.take(AudioAPI_SERVICE_NAME) - ) - } - if (PluginAbility.LYRICS in pluginInfo.abilities) { - put( - LyricsAPI::class, - result.zipline.take(LyricsAPI_SERVICE_NAME) - ) - } - if (PluginAbility.SCROBBLE in pluginInfo.abilities) { - put( - ScrobbleAPI::class, - result.zipline.take(ScrobbleAPI_SERVICE_NAME) - ) - } - } - - serviceRegistry.putAll(apiMap) - trace("start(): API ready") - } - - private fun runLogInFlowObservers() = scope.launch { - val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI - coreAPI.loggedInFlow.collect { isLoggedIn -> - loggedInStateFlow.value = isLoggedIn - } - } - - override suspend fun start() { - lifecycleMutex.withLock { - trace("start(): entered") - if (serviceRegistry.isNotEmpty()) { - trace("start(): already started, skipping") - return - } - - logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } - withContext(ziplineDispatcher.dispatcher) { - trace("start(): inside zipline dispatcher before loadOnce") - val result = ziplineLoader.loadOnce( - applicationName = applicationName, - manifestUrl = manifestUrl, - freshnessChecker = DefaultFreshnessCheckerNotFresh, - ) - when (result) { - is LoadResult.Success -> { - logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } - ziplineInstance = result.zipline - // Now we consume the initializer - val initializer = result.zipline.take(Initializer_SERVICE_NAME) - // Bind host services before initialization, so plugins can use them in their initializer - bindHostServices(result.zipline) - trace("start(): calling initializer.initialize()") - runCatching { initializer.initialize() } - .onSuccess { - consumePluginServices(result) - runLogInFlowObservers() - } - .onFailure { e -> - logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } - throw e - } - } - - is LoadResult.Failure -> { - trace("start(): loadOnce failure: ${result.exception}") - logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } - throw result.exception - } - } - } - - } - } - - override suspend fun stop() { - lifecycleMutex.withLock { - trace("stop(): entered") - withContext(ziplineDispatcher.dispatcher) { - ziplineInstance?.close() - ziplineInstance = null - for (service in serviceRegistry.values) { - try { - trace("stop(): closing service ${service::class.simpleName}") - service.close() - } catch (_: Exception) { - trace("stop(): error closing service ${service::class.simpleName}") - } - } - } - serviceRegistry.clear() - scope.cancel() - loggedInStateFlow.value = false - ziplineDispatcher.close() - trace("stop(): completed") - } - } - - override suspend fun use(block: suspend PluginServiceScope.() -> T): T { - return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { - // Create the scope with the current registry - val scope = PluginServiceScope(serviceRegistry) - // Execute the block with 'scope' as 'this' - scope.block() - } - } +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.zipline + +import app.cash.zipline.Zipline +import app.cash.zipline.ZiplineService +import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh +import app.cash.zipline.loader.LoadResult +import app.cash.zipline.loader.ManifestVerifier +import app.cash.zipline.loader.ZiplineLoader +import dev.krtirtho.plugin_interfaces.core.Initializer +import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI +import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI +import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI +import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI +import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI +import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.webview.WebViewController +import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI +import dev.krtirtho.spotube.modules.plugin.PluginAbility +import dev.krtirtho.spotube.modules.plugin.PluginCapability +import dev.krtirtho.spotube.modules.plugin.PluginEntry +import io.ktor.http.URLBuilder +import io.ktor.http.decodeURLQueryComponent +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.Path.Companion.toPath +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.reflect.KClass + +/** + * Manages the lifecycle of a Zipline plugin. + * It runs everything on its own dispatcher (different thread) as per Zipline's requirements. + * Anything it provides, must be called within that dispatcher context. + * The [use] function must be used. + * + * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well, + * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in + * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows. + * + * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called. + */ +open class ZiplinePluginService( + val applicationName: String, + private val manifestUrl: String, + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { + + override val pluginId: String = pluginInfo.id + + // QuickJS compile() uses deep C-level recursion on the native thread stack. + // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules + // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. + // We use a custom EventListener to increase maxStackSize right after the Zipline + // instance is created, before any modules are loaded. + private val ziplineDispatcher = createZiplineDispatcher() + + private fun trace(event: String) { + logger.d { "[$applicationName] $event" } + } + + private val logger by injectLogger() + private val webViewController: WebViewController by inject() + + private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) + private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> + logger.e(throwable) { "Zipline Engine Error" } + } + private val lifecycleMutex = Mutex() + private var ziplineLoader: ZiplineLoader + private var ziplineInstance: Zipline? = null + private val serviceRegistry = mutableMapOf, ZiplineService>() + + init { + val manifestPath = URLBuilder(manifestUrl) + val baseDir = + manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent + ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") + ziplineLoader = ZiplineLoader( + dispatcher = ziplineDispatcher.dispatcher, + manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, + httpClient = FileSystemHTTPClient(baseDir) + ) + } + + private val realHttpClientAPI = RealHttpClientAPI() + private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) + + private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) + private val cryptoAPI = RealCryptoAPI(scope.coroutineContext) + private val systemInformationAPI = RealSystemInformationAPI() + + private val loggedInStateFlow = MutableStateFlow(false) + override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow() + + private fun bindHostServices(zipline: Zipline) { + trace("initializer(): binding host APIs") + logger.d { "[$applicationName] Binding host APIs in initializer" } + try { + // Basic APIs + zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI) + zipline.bind( + SystemInformationAPI_SERVICE_NAME, + systemInformationAPI + ) + + // Conditional APIs based on plugin capabilities + if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { + zipline.bind( + HttpClientAPI_SERVICE_NAME, + realHttpClientAPI + ) + } + if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { + zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) + } + if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { + zipline.bind( + PersistedStorageAPI_SERVICE_NAME, + persistedStorageAPI + ) + } + } catch (e: Exception) { + logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } + throw e + } + } + + private fun consumePluginServices(result: LoadResult.Success) { + trace("start(): loadOnce success") + val apiMap = + buildMap, ZiplineService> { + put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) + + if (PluginAbility.METADATA in pluginInfo.abilities) { + put( + MetadataUserAPI::class, + result.zipline.take( + MetadataUserAPI_SERVICE_NAME + ) + ) + put( + MetadataTrackAPI::class, + result.zipline.take( + MetadataTrackAPI_SERVICE_NAME + ) + ) + put( + MetadataAlbumAPI::class, + result.zipline.take( + MetadataAlbumAPI_SERVICE_NAME + ) + ) + put( + MetadataArtistAPI::class, + result.zipline.take( + MetadataArtistAPI_SERVICE_NAME + ) + ) + put( + MetadataPlaylistAPI::class, + result.zipline.take( + MetadataPlaylistAPI_SERVICE_NAME + ) + ) + put( + MetadataBrowseAPI::class, + result.zipline.take( + MetadataBrowseAPI_SERVICE_NAME + ) + ) + put( + MetadataSearchAPI::class, + result.zipline.take( + MetadataSearchAPI_SERVICE_NAME + ) + ) + } + if (PluginAbility.AUDIO in pluginInfo.abilities) { + put( + AudioAPI::class, + result.zipline.take(AudioAPI_SERVICE_NAME) + ) + } + if (PluginAbility.LYRICS in pluginInfo.abilities) { + put( + LyricsAPI::class, + result.zipline.take(LyricsAPI_SERVICE_NAME) + ) + } + if (PluginAbility.SCROBBLE in pluginInfo.abilities) { + put( + ScrobbleAPI::class, + result.zipline.take(ScrobbleAPI_SERVICE_NAME) + ) + } + } + + serviceRegistry.putAll(apiMap) + trace("start(): API ready") + } + + private fun runLogInFlowObservers() = scope.launch { + val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI + coreAPI.loggedInFlow.collect { isLoggedIn -> + loggedInStateFlow.value = isLoggedIn + } + } + + override suspend fun start() { + lifecycleMutex.withLock { + trace("start(): entered") + if (serviceRegistry.isNotEmpty()) { + trace("start(): already started, skipping") + return + } + + logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } + withContext(ziplineDispatcher.dispatcher) { + trace("start(): inside zipline dispatcher before loadOnce") + val result = ziplineLoader.loadOnce( + applicationName = applicationName, + manifestUrl = manifestUrl, + freshnessChecker = DefaultFreshnessCheckerNotFresh) + when (result) { + is LoadResult.Success -> { + logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } + ziplineInstance = result.zipline + // Now we consume the initializer + val initializer = result.zipline.take(Initializer_SERVICE_NAME) + // Bind host services before initialization, so plugins can use them in their initializer + bindHostServices(result.zipline) + trace("start(): calling initializer.initialize()") + runCatching { initializer.initialize() } + .onSuccess { + consumePluginServices(result) + runLogInFlowObservers() + } + .onFailure { e -> + logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } + throw e + } + } + + is LoadResult.Failure -> { + trace("start(): loadOnce failure: ${result.exception}") + logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } + throw result.exception + } + } + } + + } + } + + override suspend fun stop() { + lifecycleMutex.withLock { + trace("stop(): entered") + withContext(ziplineDispatcher.dispatcher) { + ziplineInstance?.close() + ziplineInstance = null + for (service in serviceRegistry.values) { + try { + trace("stop(): closing service ${service::class.simpleName}") + service.close() + } catch (_: Exception) { + trace("stop(): error closing service ${service::class.simpleName}") + } + } + } + serviceRegistry.clear() + scope.cancel() + loggedInStateFlow.value = false + ziplineDispatcher.close() + trace("stop(): completed") + } + } + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { + // Create the scope with the current registry + val scope = PluginServiceScope(serviceRegistry) + // Execute the block with 'scope' as 'this' + scope.block() + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt index 2c0fc64b..1fd4ac7a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt @@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format { } } - class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { private val provider = CryptographyProvider.Default @@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { return CryptographyRandom.nextBytes(size) } - override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray { val hasher = provider.get(algorithm.algorithm()).hasher() return hasher.hash(data) @@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { val keyPair = provider.get(RSA.PSS).keyPairGenerator( algorithm.keySizeBits.bits, algorithm.hashAlgorithm.algorithm(), - algorithm.publicExponent.toBigInt(), - ).generateKey() + algorithm.publicExponent.toBigInt()).generateKey() Pair( keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), @@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.ECDSA -> provider.get(ECDSA).privateKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.privateKeyFormat(), privateKey ).signatureGenerator( @@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.EdDSA -> provider.get(EdDSA).privateKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.privateKeyFormat(), privateKey ).signatureGenerator().generateSignature(data) @@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.ECDSA -> provider.get(ECDSA).publicKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.publicKeyFormat(), publicKey ).signatureVerifier( @@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.EdDSA -> provider.get(EdDSA).publicKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.publicKeyFormat(), publicKey ).signatureVerifier().verifySignature( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt index db09738a..5cd23546 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.takeFrom import org.koin.core.component.KoinComponent - class RealHttpClientAPI : HttpClientAPI, KoinComponent { val logger by injectLogger() val httpClient = HttpClient() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt index 80f906cb..830cb4cc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt @@ -1,74 +1,73 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.zipline.host_apis - -import dev.krtirtho.plugin_interfaces.host_apis.Cookie -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.spotube.core.webview.WebViewController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class RealWebViewAPI( - private val scope: CoroutineScope, - private val webViewController: WebViewController, - private val pluginId: String, -) : WebViewAPI { - override fun navigateTo(url: String) { - scope.launch(Dispatchers.Main) { - webViewController.navigateTo(url, pluginId) - } - } - - override fun navigateToHTML(html: String) { - scope.launch { - webViewController.navigateToHTML(html, pluginId) - } - } - - override suspend fun getCookies(url: String): List { - return withContext(Dispatchers.Main) { - webViewController.getCookies(url) - } - } - - override suspend fun evaluateJavaScript(script: String): String? { - return withContext(Dispatchers.Main) { - webViewController.evaluateJavascript(script) - } - } - - override fun urlChangeFlow(): Flow { - return webViewController.urlChangedFlow - } - override fun webviewCreatedFlow(): Flow { - return webViewController.webviewCreatedFlow - } - override fun postMessagesFlow(): Flow { - return webViewController.postMessagesFlow - } - - override fun exitWebView() { - scope.launch(Dispatchers.Main) { - webViewController.closeWebview() - } - } - +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.zipline.host_apis + +import dev.krtirtho.plugin_interfaces.host_apis.Cookie +import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI +import dev.krtirtho.spotube.core.webview.WebViewController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RealWebViewAPI( + private val scope: CoroutineScope, + private val webViewController: WebViewController, + private val pluginId: String) : WebViewAPI { + override fun navigateTo(url: String) { + scope.launch(Dispatchers.Main) { + webViewController.navigateTo(url, pluginId) + } + } + + override fun navigateToHTML(html: String) { + scope.launch { + webViewController.navigateToHTML(html, pluginId) + } + } + + override suspend fun getCookies(url: String): List { + return withContext(Dispatchers.Main) { + webViewController.getCookies(url) + } + } + + override suspend fun evaluateJavaScript(script: String): String? { + return withContext(Dispatchers.Main) { + webViewController.evaluateJavascript(script) + } + } + + override fun urlChangeFlow(): Flow { + return webViewController.urlChangedFlow + } + override fun webviewCreatedFlow(): Flow { + return webViewController.webviewCreatedFlow + } + override fun postMessagesFlow(): Flow { + return webViewController.postMessagesFlow + } + + override fun exitWebView() { + scope.launch(Dispatchers.Main) { + webViewController.closeWebview() + } + } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt index d3c8e92e..33667956 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -45,8 +45,7 @@ data class LRCLibResponse( val artistName: String, val albumName: String, val plainLyrics: String?, - val syncedLyrics: String?, -) + val syncedLyrics: String?) class RealLRCLibLyricsAPI : LyricsAPI { private val httpClient = HttpClient { @@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI { val text = line.substring(match.range.last + 1).trim() LyricsLine(time = timeMs, text = text) }, - plainLyrics = response.plainLyrics, - ) + plainLyrics = response.plainLyrics) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt index e1268ef1..3c63c55b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt @@ -124,16 +124,14 @@ data class MusicGatewayResponse( val externalIds: ExternalIds, val id: String, val name: String, - val popularity: Int, - ) { + val popularity: Int) { @Serializable data class Album( @SerialName("album_type") val albumType: String, val artists: List, val id: String, - val name: String, - ) + val name: String) @Serializable data class Artist( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index a36a7ad9..e36e3ad7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -57,7 +57,6 @@ class ISRCProviders { } } - private var ifpiSessionToken: String? = null private val ifpiDefaultHeaders = mapOf( "Content-Type" to "application/json", @@ -76,7 +75,6 @@ class ISRCProviders { "User-Agent" to UserAgents.random() ) - private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { if (ifpiSessionToken != null) return@withContext ifpiSessionToken diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index adc839cd..92b84513 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -65,12 +65,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { Thumbnail( url = video.thumbnailUrl, width = 300, - height = 300, - ) + height = 300) ), externalUri = "https://www.youtube.com/watch?v=${video.id}", - confidence = confidence, - ) + confidence = confidence) } override val supportedQualities: List = listOf( @@ -79,15 +77,13 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ), AudioFormat( codec = "aac", container = "mp4", qualities = listOf( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ) ) @@ -98,7 +94,6 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { )) val isYouTubeID = track.id.matches(youtubeIDRegex) - logger.i { "Searching streams for track(${track.id}): ${track.title} by ${ track.artists.joinToString( @@ -119,13 +114,11 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { Thumbnail( url = videoInfo.thumbnailUrl, width = 300, - height = 300, - ) + height = 300) ), externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", confidence = 1.0f, - streams = videoInfo.audioStreams, - ) + streams = videoInfo.audioStreams) ) } } @@ -165,8 +158,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { Thumbnail( url = videoInfo.thumbnailUrl, width = 300, - height = 300, - ) + height = 300) ), externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", confidence = source.confidence, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt index a8d2e8ec..36ff187c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt @@ -36,8 +36,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class AlbumRepository( val pluginManager: PluginManager, - private val libraryRepository: LibraryRepository, -) { + private val libraryRepository: LibraryRepository) { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val plugin diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 6b2ac18d..ed4b9a11 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -110,8 +110,6 @@ fun AlbumScreen( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) - }, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index d27d9421..2cd84136 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -65,15 +65,13 @@ sealed interface AlbumScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data { + override val isSaving: Boolean = false) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( album = album, isSaved = isSaved, tracks = tracks, nextPagination = nextPagination, - isSaving = isSaving, - ) + isSaving = isSaving) } data class LoadingMore( @@ -81,8 +79,7 @@ sealed interface AlbumScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data + override val isSaving: Boolean = false) : Data } data class Error(val message: String) : AlbumScreenState @@ -99,8 +96,7 @@ class AlbumViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(AlbumScreenState.Loading) @@ -126,8 +122,7 @@ class AlbumViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -135,8 +130,7 @@ class AlbumViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -175,8 +169,7 @@ class AlbumViewModel( album = albumInfo?.first, isSaved = albumInfo?.second ?: false, tracks = tracksResult?.items ?: emptyList(), - nextPagination = tracksResult?.nextPagination, - ) + nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load album" } _state.value = AlbumScreenState.Error(e.message ?: "Unknown error") @@ -193,8 +186,7 @@ class AlbumViewModel( isSaved = currentState.isSaved, tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - isSaving = currentState.isSaving, - ) + isSaving = currentState.isSaving) } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } @@ -245,8 +237,7 @@ class AlbumViewModel( type = RemoteCollectionType.Album, id = albumId, title = title, - startTrack = track, - ) + startTrack = track) } fun refresh() { @@ -319,8 +310,7 @@ class AlbumViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 5d5afc72..f274c19e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -17,6 +17,15 @@ package dev.krtirtho.spotube.modules.artist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,19 +44,9 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PlainTooltip -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TooltipAnchorPosition -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.rememberTooltipState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -74,10 +73,6 @@ import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import org.koin.compose.koinInject -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ArtistCard @@ -118,17 +113,19 @@ fun ArtistScreen( val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - BaseScaffold( - topBar = { ApplicationMainBar() } - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { when (val currentState = state) { is ArtistScreenState.Loading -> { - ArtistLoadingContent(innerPadding) + ArtistLoadingContent() } is ArtistScreenState.Error -> { ArtistErrorContent( - innerPadding = innerPadding, message = currentState.message, onRetry = { viewModel.refresh() } ) @@ -141,25 +138,21 @@ fun ArtistScreen( state = listState, modifier = Modifier .fillMaxSize() - .padding(innerPadding) .padding(bottom = LocalAppShellBottomInset.current + 24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { + verticalArrangement = Arrangement.spacedBy(24.dp)) { item { ArtistHeaderCard( artist = currentState.artist, isSaved = savedArtistIds.contains(currentState.artist.id), onFollowClick = viewModel::toggleSavedArtist, isBlacklisted = currentState.artist.id in blacklistedArtistIds, - onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }, - ) + onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) } item { TopTracksHeader( onPlay = viewModel::playTopTracks, - onAddToQueue = viewModel::addTopTracksToQueue, - ) + onAddToQueue = viewModel::addTopTracksToQueue) } item { @@ -197,8 +190,7 @@ fun ArtistScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, - isJamGuest = isJamGuest, - ) + isJamGuest = isJamGuest) } if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { @@ -212,8 +204,7 @@ fun ArtistScreen( ) ) }, - onLoadMore = { viewModel.loadMoreAlbums() }, - ) + onLoadMore = { viewModel.loadMoreAlbums() }) } } @@ -228,8 +219,7 @@ fun ArtistScreen( ) ) }, - onLoadMore = { viewModel.loadMoreRelatedArtists() }, - ) + onLoadMore = { viewModel.loadMoreRelatedArtists() }) } } @@ -244,73 +234,62 @@ fun ArtistScreen( ) ) }, - onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }, - ) + onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }) } } } } } + } AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) } } @Composable -private fun ArtistLoadingContent(innerPadding: PaddingValues) { +private fun ArtistLoadingContent() { Box( modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { + .fillMaxSize(), + contentAlignment = Alignment.Center) { SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { + verticalArrangement = Arrangement.spacedBy(24.dp)) { Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { + .padding(horizontal = 16.dp)) { Box( modifier = Modifier .size(200.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - ) + .background(AppTheme.colors.surfaceContainerHighest)) } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { + .padding(horizontal = 16.dp)) { Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - ) + .padding(vertical = 8.dp)) } LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -321,26 +300,20 @@ private fun ArtistLoadingContent(innerPadding: PaddingValues) { @Composable private fun ArtistErrorContent( - innerPadding: PaddingValues, message: String, - onRetry: () -> Unit, -) { + onRetry: () -> Unit) { Box( modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { + .fillMaxSize(), + contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyLarge, - ) - PrimaryButton(onClick = onRetry) { + color = AppTheme.colors.error, + style = AppTheme.typography.bodyLarge) + MillenniumButton(onClick = onRetry) { Text("Retry") } } @@ -353,44 +326,34 @@ private fun ArtistHeaderCard( isSaved: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, - onBlacklistClick: () -> Unit, -) { + onBlacklistClick: () -> Unit) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val isCompact = maxWidth < 600.dp - Card( + MillenniumCard( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(20.dp), - ) { + .padding(horizontal = 12.dp, vertical = 8.dp)) { if (isCompact) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { ArtistAvatar( artist = artist, - size = 180.dp, - ) + size = 180.dp) ArtistMeta( artist = artist, - isCompact = true, - ) + isCompact = true) ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick, - ) + onBlacklistClick = onBlacklistClick) } } else { Row( @@ -398,28 +361,23 @@ private fun ArtistHeaderCard( .fillMaxWidth() .padding(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { ArtistAvatar( artist = artist, - size = 220.dp, - ) + size = 220.dp) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { + verticalArrangement = Arrangement.spacedBy(14.dp)) { ArtistMeta( artist = artist, - isCompact = false, - ) + isCompact = false) ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick, - ) + onBlacklistClick = onBlacklistClick) } } } @@ -430,30 +388,26 @@ private fun ArtistHeaderCard( @Composable private fun ArtistAvatar( artist: MetadataArtist.Detailed, - size: androidx.compose.ui.unit.Dp, -) { + size: androidx.compose.ui.unit.Dp) { Box( modifier = Modifier .size(size) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - contentAlignment = Alignment.Center, - ) { + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center) { val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() if (imageUrl.isNotBlank()) { AsyncImage( model = imageUrl, contentDescription = artist.name, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.User, contentDescription = artist.name, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(size * 0.4f), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(size * 0.4f)) } } } @@ -461,21 +415,18 @@ private fun ArtistAvatar( @Composable private fun ArtistMeta( artist: MetadataArtist.Detailed, - isCompact: Boolean, -) { + isCompact: Boolean) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = artist.name, - style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge, + style = if (isCompact) AppTheme.typography.headlineSmall else AppTheme.typography.headlineLarge, fontWeight = FontWeight.SemiBold, textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = buildString { @@ -485,74 +436,66 @@ private fun ArtistMeta( append(artist.genres.joinToString(", ")) } }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start) artist.biography?.takeIf { it.isNotBlank() }?.let { Text( text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = if (isCompact) 4 else 6, overflow = TextOverflow.Ellipsis, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, - ) + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start) } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ArtistHeaderActions( isSaved: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, - onBlacklistClick: () -> Unit, -) { + onBlacklistClick: () -> Unit) { Row( horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { if (isSaved) { - SecondaryButton(onClick = onFollowClick) { + MillenniumButton( + onClick = onFollowClick, + style = MillenniumButtonStyle.Secondary) { Text( "Following", textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } else { - PrimaryButton(onClick = onFollowClick) { + MillenniumButton(onClick = onFollowClick) { Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - PlainTooltip { - Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist") - } - }, - state = rememberTooltipState() + MillenniumTooltip( + text = if (isBlacklisted) "Remove from blacklist" else "Add to blacklist" ) { if (isBlacklisted) { - SecondaryIconButton(onClick = onBlacklistClick) { - Icon( + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = "Remove from blacklist", - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) } } else { - SecondaryIconButton(onClick = onBlacklistClick) { - Icon( + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Add to blacklist", - ) + contentDescription = "Add to blacklist") } } } @@ -562,33 +505,32 @@ private fun ArtistHeaderActions( @Composable private fun TopTracksHeader( onPlay: () -> Unit, - onAddToQueue: () -> Unit, -) { + onAddToQueue: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = "Top Tracks", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryIconButton(onClick = onPlay) { - Icon( + MillenniumIconButton( + onClick = onPlay, + style = MillenniumIconButtonStyle.Primary) { + MillenniumIcon( imageVector = Iconsax.IconsaxPlay, - contentDescription = "Play top tracks", - ) + contentDescription = "Play top tracks") } - SecondaryIconButton(onClick = onAddToQueue) { - Icon( + MillenniumIconButton( + onClick = onAddToQueue, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add top tracks to queue", - ) + contentDescription = "Add top tracks to queue") } } } @@ -598,8 +540,7 @@ private fun TopTracksHeader( private fun AlbumsSection( albums: List, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, - onLoadMore: () -> Unit, -) { + onLoadMore: () -> Unit) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -624,8 +565,7 @@ private fun AlbumsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(albums, key = { it.id }) { album -> AlbumCard(album = album) } @@ -636,8 +576,7 @@ private fun AlbumsSection( private fun RelatedArtistsSection( artists: List, onArtistClick: (MetadataArtist.Basic) -> Unit, - onLoadMore: () -> Unit, -) { + onLoadMore: () -> Unit) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -662,8 +601,7 @@ private fun RelatedArtistsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(artists, key = { it.id }) { artist -> ArtistCard(artist = artist) } @@ -674,8 +612,7 @@ private fun RelatedArtistsSection( private fun FeaturedPlaylistsSection( playlists: List, onPlaylistClick: (MetadataPlaylist) -> Unit, - onLoadMore: () -> Unit, -) { + onLoadMore: () -> Unit) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -700,8 +637,7 @@ private fun FeaturedPlaylistsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(playlists, key = { it.id }) { playlist -> PlaylistCard(playlist = playlist) } @@ -712,10 +648,9 @@ private fun FeaturedPlaylistsSection( private fun SectionHeader(title: String) { Text( text = title, - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp), - ) + modifier = Modifier.padding(horizontal = 16.dp)) } private fun formatFollowers(count: Int?): String { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index 8c89001a..9fa5ac86 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -59,8 +59,7 @@ sealed interface ArtistScreenState { val relatedArtists: List, val relatedArtistsNextPagination: PaginationStrategy?, val featuredPlaylists: List, - val featuredPlaylistsNextPagination: PaginationStrategy?, - ) : ArtistScreenState + val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState data class Error(val message: String) : ArtistScreenState } @@ -75,8 +74,7 @@ class ArtistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(ArtistScreenState.Loading) @@ -106,8 +104,7 @@ class ArtistViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -115,8 +112,7 @@ class ArtistViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -198,8 +194,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( albums = currentState.albums + result.items, - albumsNextPagination = result.nextPagination, - ) + albumsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } @@ -219,8 +214,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( relatedArtists = currentState.relatedArtists + result.items, - relatedArtistsNextPagination = result.nextPagination, - ) + relatedArtistsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more related artists" } @@ -240,8 +234,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( featuredPlaylists = currentState.featuredPlaylists + result.items, - featuredPlaylistsNextPagination = result.nextPagination, - ) + featuredPlaylistsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more featured playlists" } @@ -254,8 +247,7 @@ class ArtistViewModel( remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.ArtistTopTracks, artistId, - artistName, - ) + artistName) } fun playTopTracks() { @@ -263,8 +255,7 @@ class ArtistViewModel( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.ArtistTopTracks, artistId, - artistName, - ) + artistName) } fun playTopTracksFromTrack(track: MetadataTrack) { @@ -273,8 +264,7 @@ class ArtistViewModel( type = RemoteCollectionType.ArtistTopTracks, id = artistId, title = artistName, - startTrack = track, - ) + startTrack = track) } fun addTracksToJam(tracks: List) { remotePlaybackController.addTracksToJam(tracks) @@ -349,8 +339,7 @@ class ArtistViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -389,8 +378,7 @@ class ArtistViewModel( relatedArtists = overview.relatedArtists.items, relatedArtistsNextPagination = overview.relatedArtists.nextPagination, featuredPlaylists = overview.featuredPlaylists.items, - featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination, - ) + featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination) } else { _state.value = ArtistScreenState.Error("Failed to load artist overview") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt index ee9f4d56..1a87bd0d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt @@ -29,8 +29,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.encodeToString open class BlacklistRepository( - private val database: Database, -) { + private val database: Database) { companion object { private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks") private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists") diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt index 9d2d6261..6ec6972e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt @@ -17,6 +17,11 @@ package dev.krtirtho.spotube.modules.blacklist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,15 +36,13 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,9 +53,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset @@ -67,36 +67,31 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun BlacklistScreen() { val viewModel = koinViewModel() - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = { Text("Blacklist") }, - ) - }, - ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = { Text("Blacklist") }) BlacklistContent( viewModel = viewModel, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } /** - * The blacklist UI without its own [BaseScaffold] / app bar, so it can be embedded + * The blacklist UI without its own app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen]. */ @Composable internal fun BlacklistContent( viewModel: BlacklistViewModel, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle() val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - var filterQuery by rememberSaveable { mutableStateOf("") } - val normalizedQuery = remember(filterQuery) { filterQuery.trim().lowercase() } + val filterState = rememberTextFieldState("") + val filterQuery = filterState.text.toString() + val normalizedQuery = filterQuery.trim().lowercase() val filteredTracks = remember(blacklistedTracks, normalizedQuery) { if (normalizedQuery.isBlank()) { @@ -130,61 +125,46 @@ internal fun BlacklistContent( modifier = modifier .fillMaxSize() .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextField( - value = filterQuery, - onValueChange = { filterQuery = it }, + verticalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumTextField( + state = filterState, + label = "", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Filter blacklist...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + placeholder = "Filter blacklist...") if (isEmpty) { Box( modifier = Modifier .fillMaxSize() .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = if (hasActiveFilter) { "No blacklisted items match '$filterQuery'" } else { "No blacklisted items" }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } } else { LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues( - bottom = 16.dp + shellBottomInset, - ), - ) { + bottom = 16.dp + shellBottomInset)) { if (filteredTracks.isNotEmpty()) { item { Text( text = "Tracks", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp)) } items(filteredTracks, key = { "track_${it.id}" }) { track -> BlacklistedTrackRow( track = track, - onRemove = { viewModel.toggleTrack(track) }, - ) + onRemove = { viewModel.toggleTrack(track) }) } } @@ -192,16 +172,14 @@ internal fun BlacklistContent( item { Text( text = "Artists", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp)) } items(filteredArtists, key = { "artist_${it.id}" }) { artist -> BlacklistedArtistRow( artist = artist, - onRemove = { viewModel.toggleArtist(artist) }, - ) + onRemove = { viewModel.toggleArtist(artist) }) } } } @@ -212,21 +190,18 @@ internal fun BlacklistContent( @Composable private fun BlacklistedTrackRow( track: MetadataTrack, - onRemove: () -> Unit, -) { + onRemove: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicCircle, contentDescription = "Track", modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) AsyncImage( model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, @@ -234,32 +209,30 @@ private fun BlacklistedTrackRow( contentScale = ContentScale.Crop, modifier = Modifier .size(48.dp) - .clip(MaterialTheme.shapes.small) - .shimmerApply(), - ) + .clip(AppTheme.shapes.small) + .shimmerApply()) Column(modifier = Modifier.weight(1f)) { Text( text = track.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = track.artists.joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } - GhostIconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicSquareRemoveFilled, contentDescription = "Remove from blacklist", - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) } } } @@ -267,28 +240,24 @@ private fun BlacklistedTrackRow( @Composable private fun BlacklistedArtistRow( artist: MetadataArtist, - onRemove: () -> Unit, -) { + onRemove: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( imageVector = Iconsax.User, contentDescription = "Artist", modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) Box( modifier = Modifier .size(48.dp) .clip(CircleShape), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { val imageUrl = artist.thumbnails.firstOrNull()?.url if (!imageUrl.isNullOrBlank()) { AsyncImage( @@ -298,38 +267,35 @@ private fun BlacklistedArtistRow( modifier = Modifier .fillMaxSize() .clip(CircleShape) - .shimmerApply(), - ) + .shimmerApply()) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.User, contentDescription = artist.name, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(24.dp)) } } Column(modifier = Modifier.weight(1f)) { Text( text = artist.name, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = "Artist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - GhostIconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = "Remove from blacklist", - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt index d4315086..e7ff5210 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt @@ -27,8 +27,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class BlacklistViewModel( - private val repository: BlacklistRepository, -) : ViewModel() { + private val repository: BlacklistRepository) : ViewModel() { val blacklistedTracks: StateFlow> = repository.blacklistedTracks .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index a6c06f0d..01b115c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.devices + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,13 +35,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -56,8 +60,7 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun DevicesScreen( - navigationCommands: NavigationCommands, -) { + navigationCommands: NavigationCommands) { val viewModel = koinViewModel() val devices by viewModel.devices.collectAsStateWithLifecycle() val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() @@ -77,58 +80,51 @@ fun DevicesScreen( } } - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = { Text("Devices") }, - actions = { - if (isDiscovering && connectingToDevice == null) { - CircularProgressIndicator( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Refresh", - modifier = Modifier - .size(24.dp) - .clickable(enabled = connectingToDevice == null) { - viewModel.startDiscovery() - }, - ) - } - }, - ) - }, - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + backButton = true, + title = { Text("Devices") }, + actions = { + if (isDiscovering && connectingToDevice == null) { + MillenniumIndeterminateProgress( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp)) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = "Refresh", + modifier = Modifier + .size(24.dp) + .clickable(enabled = connectingToDevice == null) { + viewModel.startDiscovery() + }) + } + }) val shellBottomInset = LocalAppShellBottomInset.current Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - ) { + .weight(1f)) { // Error banner error?.let { errorMessage -> Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - ) { + .padding(16.dp)) { Column { Text( text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - OutlinedButton( + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium) + MillenniumButton( onClick = { viewModel.clearError() }, modifier = Modifier.padding(top = 8.dp), - ) { + style = MillenniumButtonStyle.Secondary) { Text("Dismiss") } } @@ -141,18 +137,16 @@ fun DevicesScreen( Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - ) { + .padding(16.dp)) { Column { Text( text = "Connected to ${state.host}:${state.port}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - OutlinedButton( + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + MillenniumButton( onClick = { viewModel.disconnect() }, modifier = Modifier.padding(top = 8.dp), - ) { + style = MillenniumButtonStyle.Secondary) { Text("Disconnect") } } @@ -163,20 +157,15 @@ fun DevicesScreen( modifier = Modifier .fillMaxWidth() .padding(16.dp), - contentAlignment = Alignment.CenterStart, - ) { + contentAlignment = Alignment.CenterStart) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + verticalAlignment = Alignment.CenterVertically) { + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) Text( text = "Connecting...", - style = MaterialTheme.typography.bodyMedium, - ) + style = AppTheme.typography.bodyMedium) } } } @@ -189,8 +178,7 @@ fun DevicesScreen( modifier = Modifier .fillMaxSize() .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = if (isDiscovering) { @@ -198,16 +186,14 @@ fun DevicesScreen( } else { "No devices found" }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) if (!isDiscovering) { Text( text = "Make sure the other device has \"Allow remote control\" enabled in settings.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp)) } } } @@ -219,15 +205,12 @@ fun DevicesScreen( verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues( horizontal = 16.dp, - vertical = 8.dp, - ), - ) { + vertical = 8.dp)) { items(devices.values.toList(), key = { it.key }) { device -> DeviceRow( device = device, isConnecting = connectingToDevice?.key == device.key, - onClick = { viewModel.connectToDevice(device) }, - ) + onClick = { viewModel.connectToDevice(device) }) } item { Box(modifier = Modifier.padding(bottom = shellBottomInset)) @@ -242,54 +225,46 @@ fun DevicesScreen( private fun DeviceRow( device: DiscoveredDevice, isConnecting: Boolean, - onClick: () -> Unit, -) { + onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick, enabled = !isConnecting) .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (isConnecting) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxMirroringScreen, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } Column(modifier = Modifier.weight(1f)) { Text( text = device.name.ifBlank { "Unknown Device" }, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = if (device.host.isNotBlank() && device.port > 0) { "${device.host}:${device.port}" } else { "Resolving..." }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) if (device.deviceId.isNotBlank()) { Text( text = "ID: ${device.deviceId.take(8)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f), maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt index 435feaaf..eb2e33b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -39,8 +39,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject class DevicesViewModel( - private val navigationCommands: NavigationCommands, -) : ViewModel(), KoinComponent { + private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { private val logger = Logger.withTag("DevicesViewModel") private val discoveryService: DeviceDiscoveryService by inject() private val remoteControlClient: RemoteControlClient by inject() @@ -187,8 +186,7 @@ class DevicesViewModel( host = device.host, port = device.port, deviceId = deviceId, - deviceName = deviceName, - ) + deviceName = deviceName) // Clear connecting state after connection attempt // The connectionState flow will show the actual connection status diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index a8b8213e..323cebc4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -17,25 +17,28 @@ package dev.krtirtho.spotube.modules.devices + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.ui.base.ListRowTile -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen @@ -66,83 +69,67 @@ fun PlayDestinationPickerHost() { PlaybackDestinationAction.PlayNext -> "Play next" } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = controller::dismissPicker, - title = { + title = "Where to $actionLabel?") { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Where to $actionLabel?", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - }, - content = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "$actionLabel \"${pendingRequest.title}\" on:", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = "$actionLabel \"${pendingRequest.title}\" on:", + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) - ListRowTile( - onClick = controller::playLocally, + MillenniumListTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = AppTheme.colors.primary) + }, + title = { + Text( + text = "This Device", + style = AppTheme.typography.bodyLarge) + }, + subtitle = { + Text( + text = "$actionLabel here", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) + + if (connectionState is ConnectionState.Connected) { + MillenniumListTile( + onClick = controller::playOnRemote, modifier = Modifier.fillMaxWidth(), leading = { - Icon( - imageVector = Iconsax.IconsaxCd, + MillenniumIcon( + imageVector = Iconsax.IconsaxMirroringScreen, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) }, title = { Text( - text = "This Device", - style = MaterialTheme.typography.bodyLarge, - ) + text = remoteDeviceName, + style = AppTheme.typography.bodyLarge) }, subtitle = { Text( - text = "$actionLabel here", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - - if (connectionState is ConnectionState.Connected) { - ListRowTile( - onClick = controller::playOnRemote, - modifier = Modifier.fillMaxWidth(), - leading = { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - title = { - Text( - text = remoteDeviceName, - style = MaterialTheme.typography.bodyLarge, - ) - }, - subtitle = { - Text( - text = "$actionLabel on the connected device", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } + text = "$actionLabel on the connected device", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) } - }, - actions = { - TextButton(onClick = controller::dismissPicker) { + + MillenniumButton( + onClick = controller::dismissPicker, + style = MillenniumButtonStyle.Quiet) { Text("Cancel") } - }, - ) + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index b0b42d5b..30cb314d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -17,6 +17,14 @@ package dev.krtirtho.spotube.modules.devices + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -37,16 +45,11 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -64,13 +67,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.RemoteQueueEntry -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ListRowTile -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet @@ -78,7 +74,6 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -89,11 +84,9 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import org.koin.compose.viewmodel.koinViewModel import kotlin.time.Duration.Companion.milliseconds -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RemoteControlScreen( - onDisconnect: () -> Unit, -) { + onDisconnect: () -> Unit) { val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() @@ -105,44 +98,40 @@ fun RemoteControlScreen( // stretch awkwardly on large screens. Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter, - ) { - BaseScaffold( + contentAlignment = Alignment.TopCenter) { + Column( modifier = Modifier - .fillMaxHeight(), - topBar = { - ApplicationMainBar( - title = { Text("Remote Control") }, - backButton = true, - actions = { - GhostIconButton( - onClick = viewModel::toggleQueueVisibility, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = "Queue", - tint = if (isQueueVisible) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - GhostIconButton( - onClick = { - viewModel.disconnect() - onDisconnect() - } - ) { - Icon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Disconnect", - ) - } + .fillMaxHeight() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + MillenniumIconButton( + onClick = viewModel::toggleQueueVisibility, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + }) } - ) - } - ) { padding -> + MillenniumIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect") + } + } + ) + Box(modifier = Modifier.weight(1f)) { when (connectionState) { is ConnectionState.Connected -> { RemoteControlContent( @@ -154,14 +143,13 @@ fun RemoteControlScreen( onSetVolume = viewModel::setVolume, onToggleShuffle = viewModel::toggleShuffle, onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) + modifier = Modifier.padding(bottom = shellBottomInset) ) } is ConnectionState.Connecting -> { Box( modifier = Modifier .fillMaxSize() - .padding(padding) .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { @@ -172,7 +160,6 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding) .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { @@ -183,7 +170,6 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding) .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { @@ -191,6 +177,7 @@ fun RemoteControlScreen( } } } + } } } @@ -203,8 +190,7 @@ fun RemoteControlScreen( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClick = { viewModel.toggleQueueVisibility() }, - ) + onClick = { viewModel.toggleQueueVisibility() }) ) } @@ -212,18 +198,15 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(bottom = shellBottomInset), - ) { + .padding(bottom = shellBottomInset)) { QueueSheet( isVisible = isQueueVisible, onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize(), - ) { + modifier = Modifier.fillMaxSize()) { RemoteQueueSection( queueState = queueState, onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem, - ) + onRemoveQueueItem = viewModel::removeQueueItem) } } } @@ -238,8 +221,7 @@ private fun RemoteControlContent( onSetVolume: (Float) -> Unit, onToggleShuffle: () -> Unit, onCycleLoopMode: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Box (modifier = Modifier.fillMaxSize()) { Column( modifier = modifier @@ -247,8 +229,7 @@ private fun RemoteControlContent( .padding(horizontal = 24.dp) .widthIn(max = 480.dp) .align(Alignment.TopCenter), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Spacer(modifier = Modifier.height(32.dp)) // Album art @@ -257,26 +238,22 @@ private fun RemoteControlContent( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { + .background(AppTheme.colors.surfaceVariant)) { if (playerState.currentTrackCoverUrl != null) { AsyncImage( model = playerState.currentTrackCoverUrl, contentDescription = "Album cover", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( + contentAlignment = Alignment.Center) { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicFilter, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(48.dp), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(48.dp)) } } } @@ -286,38 +263,34 @@ private fun RemoteControlContent( // Track info Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Text( text = playerState.currentTrackTitle ?: "Unknown Track", - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(8.dp)) Text( text = playerState.currentTrackArtists ?: "Unknown Artist", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) if (playerState.currentTrackAlbum != null) { Spacer(modifier = Modifier.height(4.dp)) Text( text = playerState.currentTrackAlbum!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } @@ -325,29 +298,25 @@ private fun RemoteControlContent( // Seek bar Column( - modifier = Modifier.fillMaxWidth(), - ) { - Slider( + modifier = Modifier.fillMaxWidth()) { + MillenniumSlider( value = playerState.positionMs.toFloat(), onValueChange = { onSeek(it.toLong()) }, + label = "Seek", valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = formatDuration(playerState.positionMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) Text( text = formatDuration(playerState.durationMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } @@ -357,85 +326,67 @@ private fun RemoteControlContent( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { // Shuffle - IconButton( + MillenniumIconButton( onClick = onToggleShuffle, - modifier = Modifier.size(48.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle", tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + AppTheme.colors.onSurfaceVariant + }) } // Skip previous - IconButton( + MillenniumIconButton( onClick = onSkipPrevious, - modifier = Modifier.size(56.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxPrevious, contentDescription = "Previous", - modifier = Modifier.size(32.dp), - ) + modifier = Modifier.size(32.dp)) } // Play/Pause - val baseTheme = LocalBaseUITheme.current - val circlePrimaryIconTheme = remember(baseTheme) { - baseTheme.iconButtons.primary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) - } - PrimaryIconButton( + MillenniumIconButton( onClick = onTogglePlayPause, - modifier = Modifier.size(72.dp), - theme = circlePrimaryIconTheme, - ) { - Icon( + style = MillenniumIconButtonStyle.Primary) { + MillenniumIcon( imageVector = if (playerState.isPlaying) { Iconsax.IconsaxPause } else { Iconsax.IconsaxPlay }, contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp), - ) + modifier = Modifier.size(40.dp)) } // Skip next - IconButton( + MillenniumIconButton( onClick = onSkipNext, - modifier = Modifier.size(56.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxNext, contentDescription = "Next", - modifier = Modifier.size(32.dp), - ) + modifier = Modifier.size(32.dp)) } // Loop mode - IconButton( + MillenniumIconButton( onClick = onCycleLoopMode, - modifier = Modifier.size(48.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxRepeateMusic, contentDescription = "Loop mode", tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + AppTheme.colors.onSurfaceVariant + }) } } @@ -445,21 +396,19 @@ private fun RemoteControlContent( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( imageVector = Iconsax.IconsaxVolumeHigh, contentDescription = "Volume", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(24.dp)) - Slider( + MillenniumSlider( value = playerState.volume, onValueChange = onSetVolume, + label = "Volume", valueRange = 0f..1f, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) } Spacer(modifier = Modifier.height(32.dp)) @@ -471,42 +420,35 @@ private fun RemoteControlContent( private fun RemoteQueueSection( queueState: RemoteQueueState, onPlayQueueItem: (Int) -> Unit, - onRemoveQueueItem: (String) -> Unit, -) { + onRemoveQueueItem: (String) -> Unit) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = "Queue", - style = MaterialTheme.typography.titleLarge, - ) + style = AppTheme.typography.titleLarge) if (queueState.entries.isEmpty()) { Text( text = "No queue entries", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } else { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { itemsIndexed( items = queueState.entries, - key = { index, entry -> "${entry.mediaUrl}@$index" }, - ) { index, entry -> + key = { index, entry -> "${entry.mediaUrl}@$index" }) { index, entry -> RemoteQueueItemRow( entry = entry, index = index, isCurrent = index == queueState.currentIndex, onPlayClick = { onPlayQueueItem(index) }, - onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }, - ) + onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }) } } } @@ -519,11 +461,10 @@ private fun RemoteQueueItemRow( index: Int, isCurrent: Boolean, onPlayClick: () -> Unit, - onRemoveClick: () -> Unit, -) { + onRemoveClick: () -> Unit) { var showMenu by remember { mutableStateOf(false) } - ListRowTile( + MillenniumListTile( onClick = onPlayClick, selected = isCurrent, modifier = Modifier, @@ -531,82 +472,67 @@ private fun RemoteQueueItemRow( Box( modifier = Modifier .size(48.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { if (entry.coverUrl != null) { AsyncImage( model = entry.coverUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = "${index + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } }, title = { Text( text = entry.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (isCurrent) { - MaterialTheme.colorScheme.onSecondaryContainer + AppTheme.colors.onSecondaryContainer } else { - MaterialTheme.colorScheme.onSurface - }, - ) + AppTheme.colors.onSurface + }) }, subtitle = { Text( text = entry.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) }, trailing = { Text( text = formatDuration(entry.durationMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), - ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) - } - } + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + items = listOf("Remove from queue"), + onItemClick = { + onRemoveClick() + showMenu = false + }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp)) + } + }) } ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt index 9e329f26..7c6f1557 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -44,13 +44,11 @@ data class RemotePlayerState( val currentTrackTitle: String? = null, val currentTrackArtists: String? = null, val currentTrackAlbum: String? = null, - val currentTrackCoverUrl: String? = null, -) + val currentTrackCoverUrl: String? = null) data class RemoteQueueState( val entries: List = emptyList(), - val currentIndex: Int = -1, -) + val currentIndex: Int = -1) class RemoteControlViewModel : ViewModel(), KoinComponent { private val logger = Logger.withTag("RemoteControlViewModel") @@ -105,16 +103,14 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { currentTrackTitle = event.currentTrackTitle, currentTrackArtists = event.currentTrackArtists, currentTrackAlbum = event.currentTrackAlbum, - currentTrackCoverUrl = event.currentTrackCoverUrl, - ) + currentTrackCoverUrl = event.currentTrackCoverUrl) } } private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { _queueState.value = RemoteQueueState( entries = event.entries, - currentIndex = event.currentIndex, - ) + currentIndex = event.currentIndex) } fun togglePlayPause() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt index cc1aea59..8330c0e8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt @@ -68,8 +68,7 @@ data class DownloadItem( val totalBytes: Long = 0L, val downloadedBytes: Long = 0L, val errorMessage: String? = null, - val track: MetadataTrack? = null, -) + val track: MetadataTrack? = null) sealed interface DownloadStatus { data object Queued : DownloadStatus @@ -131,8 +130,7 @@ class DownloadManager( title = track.title, artists = artistNames, album = track.album?.title, - track = track, - ) + track = track) downloadsMap.update { it + (id to item) } scope.launch { processQueue() } @@ -158,8 +156,7 @@ class DownloadManager( status = DownloadStatus.Queued, progress = 0f, downloadedBytes = 0L, - errorMessage = null, - )) + errorMessage = null)) } scope.launch { processQueue() } } @@ -203,8 +200,7 @@ class DownloadManager( map[item.id]?.let { map + (item.id to it.copy( status = DownloadStatus.Failed("No track metadata"), - errorMessage = "No track metadata", - )) + errorMessage = "No track metadata")) } ?: map } return @@ -218,8 +214,7 @@ class DownloadManager( map[item.id]?.let { map + (item.id to it.copy( status = DownloadStatus.Failed("Could not resolve stream URL"), - errorMessage = "Could not resolve stream URL. Try playing the track first.", - )) + errorMessage = "Could not resolve stream URL. Try playing the track first.")) } ?: map } return @@ -263,8 +258,7 @@ class DownloadManager( map[item.id]?.let { map + (item.id to it.copy( status = DownloadStatus.Failed(e.message ?: "Unknown error"), - errorMessage = e.message, - )) + errorMessage = e.message)) } ?: map } } finally { @@ -279,8 +273,7 @@ class DownloadManager( item: DownloadItem, tempDir: Path, downloadDir: Path, - totalSize: Long, - ) { + totalSize: Long) { val segmentSize = totalSize / SEGMENT_COUNT val segments = (0 until SEGMENT_COUNT).map { i -> val start = i * segmentSize @@ -317,8 +310,7 @@ class DownloadManager( item: DownloadItem, segment: Segment, outputPath: Path, - totalSize: Long, - ) { + totalSize: Long) { val response = httpClient.get(url) { headers { append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") @@ -341,8 +333,7 @@ class DownloadManager( filename: String, item: DownloadItem, tempDir: Path, - downloadDir: Path, - ) { + downloadDir: Path) { val tempFile = tempDir / "${item.id}.tmp" val response = httpClient.get(url) @@ -373,8 +364,7 @@ class DownloadManager( map + (id to current.copy( progress = progress, downloadedBytes = newDownloaded, - totalBytes = totalSize, - )) + totalBytes = totalSize)) } ?: map } } @@ -423,8 +413,7 @@ class DownloadManager( album = item.album, trackNumber = track.trackNumber, discNumber = track.discNumber, - coverBytes = coverBytes, - ) + coverBytes = coverBytes) } catch (e: Exception) { logger.w(e) { "Failed to write audio metadata for ${item.title}" } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt index fa26af13..9168226f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt @@ -17,20 +17,25 @@ package dev.krtirtho.spotube.modules.downloads + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -46,9 +51,8 @@ fun DownloadProgressIcon( modifier: Modifier = Modifier, size: Dp = 24.dp, strokeWidth: Dp = 2.dp, - tint: Color = MaterialTheme.colorScheme.onSurface, - progressTint: Color = MaterialTheme.colorScheme.primary, -) { + tint: Color = AppTheme.colors.onSurface, + progressTint: Color = AppTheme.colors.primary) { val downloadsViewModel = koinViewModel() val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() @@ -68,33 +72,36 @@ fun DownloadProgressIcon( modifier = modifier.size(size), contentAlignment = Alignment.Center ) { - CircularProgressIndicator( - progress = { progress }, - modifier = Modifier.size(size), - strokeWidth = strokeWidth, - color = progressTint, - ) - Icon( + Canvas(Modifier.size(size)) { + val diameter = this.size.minDimension + val stroke = strokeWidth.toPx() + drawArc( + color = progressTint, + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = stroke), + topLeft = Offset(stroke / 2f, stroke / 2f), + size = Size(diameter - stroke, diameter - stroke)) + } + MillenniumIcon( imageVector = icon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(size * 0.6f), - ) + modifier = Modifier.size(size * 0.6f)) } } else { - Icon( + MillenniumIcon( imageVector = icon, contentDescription = contentDescription, tint = tint, - modifier = modifier.size(size), - ) + modifier = modifier.size(size)) } } @Composable fun DownloadBadgeIndicator( - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val downloadsViewModel = koinViewModel() val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() @@ -105,7 +112,7 @@ fun DownloadBadgeIndicator( modifier = modifier .size(8.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) + .background(AppTheme.colors.primary) ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt index 5d8b8dcc..b8cad5cf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.downloads + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,12 +38,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -70,26 +75,26 @@ fun DownloadsScreen(modifier: Modifier = Modifier) { Box(modifier = Modifier.fillMaxSize()) { Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - ) { + modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter)) { val hasCompleted = downloads.any { it.status == DownloadStatus.Completed } if (hasCompleted) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = viewModel::clearCompleted) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Text( - text = " Clear completed", - style = MaterialTheme.typography.labelLarge, - ) + horizontalArrangement = Arrangement.End) { + MillenniumButton( + onClick = viewModel::clearCompleted, + style = MillenniumButtonStyle.Quiet) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp)) + Text( + text = " Clear completed", + style = AppTheme.typography.labelLarge) + } } } } @@ -97,26 +102,22 @@ fun DownloadsScreen(modifier: Modifier = Modifier) { if (downloads.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = "No downloads yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } } else { LazyColumn( contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { items(downloads, key = { it.id }) { item -> DownloadListRow( item = item, onCancel = { viewModel.cancel(item.id) }, onRetry = { viewModel.retry(item.id) }, - onRemove = { viewModel.remove(item.id) }, - ) + onRemove = { viewModel.remove(item.id) }) } } } @@ -129,24 +130,22 @@ private fun DownloadListRow( item: DownloadItem, onCancel: () -> Unit, onRetry: () -> Unit, - onRemove: () -> Unit, -) { + onRemove: () -> Unit) { val rowBackgroundColor = when (item.status) { - is DownloadStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) - is DownloadStatus.Downloading -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - else -> MaterialTheme.colorScheme.surface + is DownloadStatus.Failed -> AppTheme.colors.errorContainer.copy(alpha = 0.2f) + is DownloadStatus.Downloading -> AppTheme.colors.primaryContainer.copy(alpha = 0.15f) + else -> AppTheme.colors.surface } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) - .clip(MaterialTheme.shapes.small) + .clip(AppTheme.shapes.small) .background(rowBackgroundColor) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(10.dp)) { val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails) ?.firstOrNull()?.url @@ -162,117 +161,110 @@ private fun DownloadListRow( Box( modifier = Modifier .size(44.dp) - .clip(MaterialTheme.shapes.small), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small), + contentAlignment = Alignment.Center) { AsyncImage( model = imageRequest, contentDescription = item.title, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + modifier = Modifier.fillMaxSize()) if (item.status == DownloadStatus.Downloading) { - LinearProgressIndicator( - progress = { item.progress }, + MillenniumProgress( + progress = item.progress, modifier = Modifier .fillMaxWidth() .height(3.dp) - .align(Alignment.BottomCenter), - ) + .align(Alignment.BottomCenter)) } } Column(modifier = Modifier.weight(1f)) { Text( text = item.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = item.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) when (item.status) { is DownloadStatus.Downloading -> { val percent = (item.progress * 100).toInt() Text( text = "Downloading... $percent%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } is DownloadStatus.Queued -> { Text( text = "Queued", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } is DownloadStatus.Failed -> { Text( text = "Failed: ${item.errorMessage ?: "Unknown error"}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.error, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } is DownloadStatus.Cancelled -> { Text( text = "Cancelled", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } is DownloadStatus.Completed -> { Text( text = "Completed", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } } } when (item.status) { is DownloadStatus.Downloading, is DownloadStatus.Queued -> { - IconButton(onClick = onCancel) { - Icon( + MillenniumIconButton( + onClick = onCancel, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, contentDescription = "Cancel", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { - IconButton(onClick = onRetry) { - Icon( + MillenniumIconButton( + onClick = onRetry, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxRefreshRight, contentDescription = "Retry", - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } } is DownloadStatus.Completed -> { - IconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxTrash, contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt index 24d8ff86..b1aef155 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt @@ -25,13 +25,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn class DownloadsViewModel( - private val downloadManager: DownloadManager, -) : ViewModel() { + private val downloadManager: DownloadManager) : ViewModel() { val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), - emptyList(), - ) + emptyList()) fun downloadTrack(track: MetadataTrack) { downloadManager.enqueue(track) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index 4b6782fe..3ab5a299 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -17,6 +17,14 @@ package dev.krtirtho.spotube.modules.home + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,14 +38,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumPullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,7 +59,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowse import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.ui.base.ChipTab import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ArtistCard @@ -79,8 +82,6 @@ import org.koin.compose.koinInject import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(viewModel: HomeScreenViewModel) { val navigationCommands = koinInject() @@ -120,58 +121,56 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { } } - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = false, - title = { - Text("Browse") - }, - actions = { - if (!isDesktop) { - IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = "Devices", - ) - } - IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) { - Icon( - imageVector = Iconsax.User, - contentDescription = "Group Jam", - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + backButton = false, + title = { + Text("Browse") + }, + actions = { + if (!isDesktop) { + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Devices) }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = "Devices") } - }, - ) - }, - ) { innerPadding -> + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Jam) }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = "Group Jam") + } + } + }) Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .weight(1f) ) { if (isDesktop) { HomeContent( listState = listState, state = state, onRetry = { viewModel.refresh() }, - onGenreSelected = { viewModel.selectGenre(it) }, - ) + onGenreSelected = { viewModel.selectGenre(it) }) } else { - PullToRefreshBox( + MillenniumPullToRefreshBox( isRefreshing = state is HomeScreenState.Loading, onRefresh = { viewModel.refresh() }, - modifier = Modifier.fillMaxSize(), - ) { + modifier = Modifier.fillMaxSize()) { HomeContent( listState = listState, state = state, onRetry = { viewModel.refresh() }, - onGenreSelected = { viewModel.selectGenre(it) }, - ) + onGenreSelected = { viewModel.selectGenre(it) }) } } @@ -189,8 +188,7 @@ private fun HomeContent( listState: androidx.compose.foundation.lazy.LazyListState, state: HomeScreenState, onRetry: () -> Unit, - onGenreSelected: (String) -> Unit, -) { + onGenreSelected: (String) -> Unit) { val shellBottomInset = LocalAppShellBottomInset.current val contentPadding = remember(shellBottomInset) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) @@ -200,37 +198,31 @@ private fun HomeContent( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { + verticalArrangement = Arrangement.spacedBy(24.dp)) { when (state) { is HomeScreenState.Loading -> { item { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { repeat(3) { SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -244,8 +236,7 @@ private fun HomeContent( item { ErrorDisplay( errorMessage = state.message, - onRetry = onRetry, - ) + onRetry = onRetry) } } @@ -261,8 +252,7 @@ private fun HomeContent( GenreTabs( genres = state.genres, selectedGenreId = state.selectedGenreId, - onGenreSelected = onGenreSelected, - ) + onGenreSelected = onGenreSelected) } } @@ -273,24 +263,20 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -301,8 +287,7 @@ private fun HomeContent( HomeSection( title = section.title, subtitle = section.description, - items = section.items, - ) + items = section.items) } } @@ -311,24 +296,20 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -346,36 +327,30 @@ private fun HomeSection( title: String, subtitle: String? = null, items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val rowState = rememberLazyListState() Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { if (!subtitle.isNullOrEmpty()) Text( text = subtitle, - style = MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.secondary, - fontWeight = FontWeight.Medium, - ), - modifier = Modifier.padding(horizontal = 16.dp), - ) + style = AppTheme.typography.labelMedium.copy( + color = AppTheme.colors.secondary, + fontWeight = FontWeight.Medium), + modifier = Modifier.padding(horizontal = 16.dp)) Text( text = title, - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp), - ) + modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp)) LazyRow( state = rowState, modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(items) { browseItem -> when (browseItem) { is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data) @@ -393,22 +368,24 @@ private fun HomeSection( private fun GenreTabs( genres: List, selectedGenreId: String?, - onGenreSelected: (String) -> Unit, -) { + onGenreSelected: (String) -> Unit) { val rowState = rememberLazyListState() LazyRow( state = rowState, modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(6.dp)) { items(genres) { genre -> - ChipTab( - text = genre.name, - selected = selectedGenreId == genre.id, + MillenniumButton( onClick = { onGenreSelected(genre.id) }, - ) + style = if (selectedGenreId == genre.id) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + }) { + Text(genre.name) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt index ecffbd25..5255c8dc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt @@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch - @OptIn(ExperimentalCoroutinesApi::class) class HomeScreenRepository( private val pluginManager: PluginManager diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt index f77c8d86..667fe23f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt @@ -51,15 +51,13 @@ sealed interface HomeScreenState { override val genres: List, override val selectedGenreId: String?, override val browseSections: Map?>, - override val paginationStrategies: Map, - ) : Data { + override val paginationStrategies: Map) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( featuredItems = featuredItems, genres = genres, selectedGenreId = selectedGenreId, browseSections = browseSections, - paginationStrategies = paginationStrategies, - ) + paginationStrategies = paginationStrategies) } data class LoadingMore( @@ -67,8 +65,7 @@ sealed interface HomeScreenState { override val genres: List, override val selectedGenreId: String?, override val browseSections: Map?>, - override val paginationStrategies: Map, - ) : Data + override val paginationStrategies: Map) : Data } data class Error(val message: String) : HomeScreenState @@ -111,8 +108,7 @@ class HomeScreenViewModel( genres = genres, selectedGenreId = firstGenreId, browseSections = browseSections, - paginationStrategies = paginationStrategies, - ) + paginationStrategies = paginationStrategies) }.onFailure { e -> logger.e(e) { "Failed to load home screen data" } state.value = HomeScreenState.Error(e.message ?: "Unknown error") @@ -135,8 +131,7 @@ class HomeScreenViewModel( genres = current.genres, selectedGenreId = genreId, browseSections = current.browseSections + (genreId to (result?.items ?: emptyList())), - paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination), - ) + paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination)) } } } @@ -156,8 +151,7 @@ class HomeScreenViewModel( genres = currentState.genres, selectedGenreId = genreId, browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))), - paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination), - ) + paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination)) } }.onFailure { e -> logger.e(e) { "Failed to load more home screen data" } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index f05cbc50..8693e7d3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.modules.jam +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,22 +26,24 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.composables.MillenniumTextField + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -65,29 +68,26 @@ import org.koin.compose.viewmodel.koinViewModel */ @Composable fun JamScreen( - navigationCommands: NavigationCommands, -) { + navigationCommands: NavigationCommands) { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = { Text("Group Jam") }, - ) - }, - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + backButton = true, + title = { Text("Group Jam") }) Column( modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + .fillMaxWidth() + .weight(1f) .padding(16.dp) .padding(bottom = shellBottomInset) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { state.error?.let { error -> ErrorBanner(text = error, onDismiss = viewModel::clearError) } @@ -96,16 +96,14 @@ fun JamScreen( !state.isActive -> CreateOrJoinView( state = state, onCreate = viewModel::createSession, - onJoin = viewModel::joinWithCode, - ) + onJoin = viewModel::joinWithCode) else -> SessionView( state = state, onShareCode = viewModel::shareRoomCode, onLeave = viewModel::leave, onKick = viewModel::kickParticipant, - onBan = viewModel::banParticipant, - ) + onBan = viewModel::banParticipant) } } } @@ -116,10 +114,11 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = text, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - OutlinedButton(onClick = onDismiss) { + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium) + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Dismiss") } } @@ -129,42 +128,36 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) { private fun CreateOrJoinView( state: JamUiState, onCreate: () -> Unit, - onJoin: (String) -> Unit, -) { + onJoin: (String) -> Unit) { var tab by rememberSaveable { mutableIntStateOf(0) } - var pasted by rememberSaveable { mutableStateOf("") } + val codeState = rememberTextFieldState() Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) if (!state.brokerConfigured) { Text( text = "No jam broker configured — set one up in Settings to host or join a session.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.error) } else { Text( text = "Broker: ${state.brokerHost}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - SegmentedButton( + MillenniumSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + MillenniumSegmentedButton( selected = tab == 0, onClick = { tab = 0 }, - shape = SegmentedButtonDefaults.itemShape(0, 2), - ) { Text("Create") } - SegmentedButton( + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { Text("Create") } + MillenniumSegmentedButton( selected = tab == 1, onClick = { tab = 1 }, - shape = SegmentedButtonDefaults.itemShape(1, 2), - ) { Text("Join") } + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { Text("Join") } } if (tab == 0) { @@ -172,13 +165,11 @@ private fun CreateOrJoinView( Text( text = "Start a session as the host. You'll get a 6-character room code to " + "share with friends; you control the queue.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + MillenniumButton( onClick = onCreate, - enabled = state.brokerConfigured, - ) { + enabled = state.brokerConfigured) { Text("Create Session") } } @@ -186,21 +177,17 @@ private fun CreateOrJoinView( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = "Enter the 6-character room code the host shared with you.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - OutlinedTextField( - value = pasted, - onValueChange = { pasted = JamRoomCode.normalize(it) }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Room code") }, - placeholder = { Text("ABC123") }, - singleLine = true, - ) - Button( - onClick = { onJoin(pasted) }, - enabled = state.brokerConfigured && JamRoomCode.isValid(pasted), - ) { + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + MillenniumTextField( + state = codeState, + label = "Room code", + placeholder = "ABC123", + modifier = Modifier.fillMaxWidth()) + val normalizedCode = JamRoomCode.normalize(codeState.text.toString()) + MillenniumButton( + onClick = { onJoin(normalizedCode) }, + enabled = state.brokerConfigured && JamRoomCode.isValid(normalizedCode)) { Text("Join Session") } } @@ -214,44 +201,39 @@ private fun SessionView( onShareCode: () -> Unit, onLeave: () -> Unit, onKick: (String) -> Unit, - onBan: (String) -> Unit, -) { + onBan: (String) -> Unit) { val isHost = state.role == JamRole.Host Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { if (!state.isConnected) { Text( text = "Connecting to the session…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } ParticipantsSection( participants = state.participants, isHost = isHost, onKick = onKick, - onBan = onBan, - ) + onBan = onBan) if (isHost) { - HorizontalDivider() + MillenniumSeparator() Text( text = "Invite someone", - style = MaterialTheme.typography.titleSmall, - ) + style = AppTheme.typography.titleSmall) RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) } - HorizontalDivider() + MillenniumSeparator() Text( text = "The queue and playback controls are in the player at the bottom of the app — " + "the jam queue is shared with every participant.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) LeaveButton(onLeave) } @@ -262,45 +244,40 @@ private fun ParticipantsSection( participants: List, isHost: Boolean, onKick: (String) -> Unit, - onBan: (String) -> Unit, -) { + onBan: (String) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Participants (${participants.size})", - style = MaterialTheme.typography.titleSmall, - ) + style = AppTheme.typography.titleSmall) participants.forEach { participant -> Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = participant.displayName, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) if (participant.isHost) { Text( text = "Host", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } if (isHost && !participant.isHost) { - OutlinedButton( + MillenniumButton( onClick = { onKick(participant.id) }, modifier = Modifier.height(32.dp), - ) { - Text("Kick", style = MaterialTheme.typography.labelSmall) + style = MillenniumButtonStyle.Secondary) { + Text("Kick", style = AppTheme.typography.labelSmall) } - OutlinedButton( + MillenniumButton( onClick = { onBan(participant.id) }, modifier = Modifier.height(32.dp), - ) { - Text("Ban", style = MaterialTheme.typography.labelSmall) + style = MillenniumButtonStyle.Secondary) { + Text("Ban", style = AppTheme.typography.labelSmall) } } } @@ -311,8 +288,7 @@ private fun ParticipantsSection( @Composable private fun RoomCodeBox( code: String, - onShare: () -> Unit, -) { + onShare: () -> Unit) { val clipboard = LocalClipboardManager.current val viewModel: JamViewModel = koinViewModel() @@ -320,18 +296,20 @@ private fun RoomCodeBox( SelectionContainer { Text( text = code, - style = MaterialTheme.typography.displaySmall, + style = AppTheme.typography.displaySmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 8.dp), - ) + color = AppTheme.colors.primary, + modifier = Modifier.padding(vertical = 8.dp)) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { clipboard.setText(AnnotatedString(code)) }) { + MillenniumButton( + onClick = { clipboard.setText(AnnotatedString(code)) }) { Text("Copy") } if (viewModel.supportsNativeShare) { - OutlinedButton(onClick = onShare) { + MillenniumButton( + onClick = onShare, + style = MillenniumButtonStyle.Secondary) { Text("Share") } } @@ -341,7 +319,9 @@ private fun RoomCodeBox( @Composable private fun LeaveButton(onLeave: () -> Unit) { - OutlinedButton(onClick = onLeave) { + MillenniumButton( + onClick = onLeave, + style = MillenniumButtonStyle.Secondary) { Text("Leave Session") } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt index 92dcc936..c361668d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -43,14 +43,12 @@ data class JamUiState( val roomCode: String? = null, val brokerHost: String = "", val brokerConfigured: Boolean = false, - val error: String? = null, -) + val error: String? = null) class JamViewModel( private val jamRoomService: JamRoomService, private val shareService: ShareService, - private val settingsProvider: SettingsProvider, -) : ViewModel() { + private val settingsProvider: SettingsProvider) : ViewModel() { private val _localError = MutableStateFlow(null) @@ -64,8 +62,7 @@ class JamViewModel( jamRoomService.roomCode, jamRoomService.connectionError, settingsProvider.settingsState, - _localError, - ) { values -> + _localError) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? @Suppress("UNCHECKED_CAST") @@ -84,8 +81,7 @@ class JamViewModel( roomCode = roomCode, brokerHost = settings?.jamBroker?.host.orEmpty(), brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), - error = localError ?: connectionError, - ) + error = localError ?: connectionError) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) // ---------- Session lifecycle ---------- diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt index 9c1f30c2..c9dc4bca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt @@ -17,12 +17,16 @@ package dev.krtirtho.spotube.modules.jam -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row + import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import androidx.compose.ui.unit.dp /** * Dialog shown when the user tries to play a collection while a jam session is active. @@ -33,31 +37,30 @@ fun PlayDestinationPicker( visible: Boolean, onDismiss: () -> Unit, onPlayLocally: () -> Unit, - onSuggestToJam: () -> Unit, -) { + onSuggestToJam: () -> Unit) { if (!visible) return - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text("Play Where?", style = MaterialTheme.typography.titleLarge) - }, - content = { - Text( - text = "You have an active jam session. Choose where to play this collection.", - style = MaterialTheme.typography.bodyMedium, - ) - }, - actions = { - TextButton(onClick = onDismiss) { + title = "Play Where?") { + Text( + text = "You have an active jam session. Choose where to play this collection.", + style = AppTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Quiet) { Text("Cancel") } - TextButton(onClick = onPlayLocally) { + MillenniumButton( + onClick = onPlayLocally, + style = MillenniumButtonStyle.Quiet) { Text("Play here") } - Button(onClick = onSuggestToJam) { + MillenniumButton(onClick = onSuggestToJam) { Text("Suggest to Jam") } - }, - ) + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt index bc1129cf..74a880ab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt @@ -299,8 +299,7 @@ class LibraryRepository( isPublic: Boolean, isCollaborating: Boolean, imageBase64: String, - trackIds: List, - ): MetadataPlaylist? { + trackIds: List): MetadataPlaylist? { return authPlugin?.let { plugin -> pluginManager.withScope { plugin.use { @@ -310,8 +309,7 @@ class LibraryRepository( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) playlistCache.invalidateAll() savedPlaylistIds.value += result.id result @@ -327,8 +325,7 @@ class LibraryRepository( isPublic: Boolean?, isCollaborating: Boolean?, imageBase64: String?, - trackIds: List?, - ): MetadataPlaylist? { + trackIds: List?): MetadataPlaylist? { return authPlugin?.let { plugin -> pluginManager.withScope { plugin.use { @@ -339,8 +336,7 @@ class LibraryRepository( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) playlistCache.invalidateAll() result } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index fdc33bfa..1dbef564 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -17,24 +17,26 @@ package dev.krtirtho.spotube.modules.library + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.ChipTab import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.downloads.DownloadsScreen import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen @@ -47,7 +49,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryScreen( libraryState: LibraryState = koinInject(), @@ -57,57 +58,52 @@ fun LibraryScreen( val searchMode by libraryState.searchMode.collectAsState() val isLargeScreen = appShellViewModel.useSidebar() - BaseScaffold( - topBar = { - Column { - ApplicationMainBar( - backButton = false, - title = { - if (!isLargeScreen) Text("Your Library") - }, - actions = { - if (!isLargeScreen) { - IconButton( - onClick = { - libraryState.setSearchMode(!searchMode) - }, - ) { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) + Column(modifier = Modifier.fillMaxSize()) { + Column { + ApplicationMainBar( + backButton = false, + title = { + if (!isLargeScreen) Text("Your Library") + }, + actions = { + if (!isLargeScreen) { + MillenniumIconButton( + icon = Iconsax.IconsaxSearchBroken, + contentDescription = "Search", + onClick = { + libraryState.setSearchMode(!searchMode) + }, + style = MillenniumIconButtonStyle.Quiet) + } + } + ) + if (!isLargeScreen) + Column( + modifier = Modifier.padding(horizontal = 12.dp) + ) { + Spacer(modifier = Modifier.height(4.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(LibraryTab.entries.size) { index -> + val tab = LibraryTab.entries[index] + MillenniumButton( + onClick = { libraryState.onTabSelected(tab) }, + style = if (currentTab == tab) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + }) { + Text(tab.title) } } } - ) - if (!isLargeScreen) - Column( - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Spacer(modifier = Modifier.height(4.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(LibraryTab.entries.size) { index -> - val tab = LibraryTab.entries[index] - ChipTab( - selected = currentTab == tab, - onClick = { libraryState.onTabSelected(tab) }, - ) { - Text(tab.title) - } - } - } - Spacer(modifier = Modifier.height(12.dp)) - } - } - - + Spacer(modifier = Modifier.height(12.dp)) + } } - ) { innerPadding -> + Column( modifier = Modifier - .padding(innerPadding) + .weight(1f) .padding(horizontal = 12.dp) ) { when (currentTab) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index 106e4e8e..c9d0832c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -17,6 +17,9 @@ package dev.krtirtho.spotube.modules.library.album + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,8 +33,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,16 +43,17 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @@ -62,6 +66,13 @@ fun LibraryAlbumsScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() + val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "") + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } + LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } .map { layoutInfo -> @@ -83,19 +94,11 @@ fun LibraryAlbumsScreen( Column(modifier = modifier) { AnimatedVisibility(searchMode){ - TextField( - value = (state as? LibraryAlbumsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, + MillenniumTextField( + state = searchState, + label = "Search", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved albums...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - } - ) + placeholder = "Search saved albums...") } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -116,8 +119,7 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -127,8 +129,7 @@ fun LibraryAlbumsScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ErrorDisplay( errorMessage = (state as LibraryAlbumsState.Error).message, - onRetry = { viewModel.refresh() }, - ) + onRetry = { viewModel.refresh() }) } } @@ -167,8 +168,7 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 42534e03..4b48f0c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -46,8 +46,7 @@ sealed interface LibraryAlbumsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -63,15 +62,13 @@ sealed interface LibraryAlbumsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, - ) + nextPagination = nextPagination) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -91,8 +88,7 @@ sealed interface LibraryAlbumsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryAlbumsViewModel( - private val repository: LibraryRepository, -) : ViewModel(), KoinComponent { + private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryAlbumsState.Loading) @@ -117,8 +113,7 @@ class LibraryAlbumsViewModel( val result = repository.savedAlbums() _state.value = LibraryAlbumsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load albums" } _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") @@ -132,8 +127,7 @@ class LibraryAlbumsViewModel( _state.value = LibraryAlbumsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index fc9b0714..9056f51d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -17,6 +17,9 @@ package dev.krtirtho.spotube.modules.library.artist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,8 +33,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,16 +43,17 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @@ -57,11 +61,17 @@ import org.koin.compose.viewmodel.koinViewModel fun LibraryArtistsScreen( modifier: Modifier = Modifier, searchMode: Boolean = false, - viewModel: LibraryArtistsViewModel = koinViewModel(), -) { + viewModel: LibraryArtistsViewModel = koinViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() + val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "") + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } + LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } .map { layoutInfo -> @@ -83,19 +93,11 @@ fun LibraryArtistsScreen( Column(modifier = modifier) { AnimatedVisibility(searchMode){ - TextField( - value = (state as? LibraryArtistsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, + MillenniumTextField( + state = searchState, + label = "Search", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved artists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + placeholder = "Search saved artists...") } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -109,15 +111,13 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { items(8) { SkeletonTree(true) { AvatarCard( title = "Artist Name", subtitle = "Artist", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -127,8 +127,7 @@ fun LibraryArtistsScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ErrorDisplay( errorMessage = (state as LibraryArtistsState.Error).message, - onRetry = { viewModel.refresh() }, - ) + onRetry = { viewModel.refresh() }) } } @@ -153,13 +152,11 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { items(dataState.items, key = { it.id }) { artist -> ArtistCard( artist = artist, - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) } // if (state is LibraryArtistsState.Data.LoadingMore) { @@ -168,8 +165,7 @@ fun LibraryArtistsScreen( AvatarCard( title = "Artist Name", subtitle = "Artist", - imageURL = "https://placehold.co/400x400", - ) + imageURL = "https://placehold.co/400x400") } } // } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt index aeb1564c..8d8b2cbb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -46,8 +46,7 @@ sealed interface LibraryArtistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -60,15 +59,13 @@ sealed interface LibraryArtistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, - ) + nextPagination = nextPagination) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -85,8 +82,7 @@ sealed interface LibraryArtistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryArtistsViewModel( - private val repository: LibraryRepository, -) : ViewModel(), KoinComponent { + private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryArtistsState.Loading) @@ -115,11 +111,9 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri, - ) + externalUri = it.externalUri) } ?: emptyList(), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load artists" } _state.value = LibraryArtistsState.Error(e.message ?: "Unknown error") @@ -135,14 +129,12 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri, - ) + externalUri = it.externalUri) } ?: emptyList() _state.value = LibraryArtistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + newItems, - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more artists" } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index d3a93160..288df6e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -17,6 +17,15 @@ package dev.krtirtho.spotube.modules.library.local_tracks + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -29,22 +38,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.foundation.text.input.rememberTextFieldState + +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.drop import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset @@ -77,63 +85,62 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } + val searchState = rememberTextFieldState(state.query) + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } + Box(modifier = modifier.fillMaxWidth()) { Column( modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { AnimatedVisibility(searchMode) { - TextField( - value = state.query, - onValueChange = viewModel::onQueryChange, - modifier = Modifier - .fillMaxWidth(), - placeholder = { - Text( - if (state.currentFolderPath == null) { - "Search folders or tracks" - } else { - "Search tracks in folder" - } - ) - }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - }, - trailingIcon = { - Icon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Refresh", - modifier = Modifier.clickable { viewModel.refresh() } - ) - } - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search") + MillenniumTextField( + state = searchState, + label = "Search", + modifier = Modifier.weight(1f), + placeholder = if (state.currentFolderPath == null) { + "Search folders or tracks" + } else { + "Search tracks in folder" + }) + MillenniumIconButton( + icon = Iconsax.IconsaxRefreshRight, + contentDescription = "Refresh", + onClick = { viewModel.refresh() }, + style = MillenniumIconButtonStyle.Quiet) + } } if (state.currentFolderPath != null) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = viewModel::navigateUp) { - Icon(imageVector = Iconsax.ArrowLeft3, contentDescription = "Back") - } + MillenniumIconButton( + icon = Iconsax.ArrowLeft3, + contentDescription = "Back", + onClick = viewModel::navigateUp, + style = MillenniumIconButtonStyle.Quiet) Text(viewModel.currentFolder()?.name ?: "Folder") } - IconButton(onClick = { - viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } - }) { - Icon( - imageVector = Iconsax.IconsaxPlayCircle2, - contentDescription = "Play folder" - ) - } + MillenniumIconButton( + icon = Iconsax.IconsaxPlayCircle2, + contentDescription = "Play folder", + onClick = { + viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } + }, + style = MillenniumIconButtonStyle.Quiet) } } @@ -144,17 +151,18 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Allow audio permission to show local tracks") - TextButton(onClick = permissionState.requestPermission) { + MillenniumButton( + onClick = permissionState.requestPermission, + style = MillenniumButtonStyle.Quiet) { Text("Grant access") } } } } else if (state.isRefreshing && folders.isEmpty() && tracks.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + MillenniumIndeterminateProgress() } } else if (state.currentFolderPath == null) { if (folders.isEmpty()) { @@ -164,17 +172,16 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean } else { LazyColumn(contentPadding = contentPadding) { items(folders, key = { it.path }) { folder -> - ListItem( - headlineContent = { Text(folder.name) }, - supportingContent = { Text("${folder.trackCount} track(s)") }, - leadingContent = { - Icon( + MillenniumListTile( + title = { Text(folder.name) }, + subtitle = { Text("${folder.trackCount} track(s)") }, + leading = { + MillenniumIcon( imageVector = Iconsax.IconsaxFolderOpen, contentDescription = null ) }, - modifier = Modifier.clickable { viewModel.openFolder(folder.path) }, - ) + onClick = { viewModel.openFolder(folder.path) }) } } } @@ -186,30 +193,28 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean } else { LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) { items(tracks, key = { it.path }) { track -> - ListItem( - headlineContent = { Text(track.name) }, - supportingContent = { + MillenniumListTile( + title = { Text(track.name) }, + subtitle = { val artists = track.artists.joinToString().ifBlank { "Unknown artist" } val album = track.album?.takeIf { it.isNotBlank() } Text(if (album == null) artists else "$artists - $album") }, - leadingContent = { - Icon( + leading = { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicCircle, contentDescription = null ) }, - trailingContent = { - IconButton(onClick = { viewModel.playTrack(track.path) }) { - Icon( - imageVector = Iconsax.Play, - contentDescription = "Play" - ) - } + trailing = { + MillenniumIconButton( + icon = Iconsax.Play, + contentDescription = "Play", + onClick = { viewModel.playTrack(track.path) }, + style = MillenniumIconButtonStyle.Quiet) }, - modifier = Modifier.clickable { viewModel.playTrack(track.path) }, - ) + onClick = { viewModel.playTrack(track.path) }) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index 0987ff7b..d1897a74 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -35,13 +35,11 @@ data class LibraryLocalTracksState( val folders: List = emptyList(), val currentFolderPath: String? = null, val query: String = "", - val error: String? = null, -) + val error: String? = null) class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, - private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { + private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val _state = MutableStateFlow(LibraryLocalTracksState()) val state: StateFlow = _state.asStateFlow() @@ -52,8 +50,7 @@ class LibraryLocalTracksViewModel( _state.update { it.copy( folders = cache.folders, - error = null, - ) + error = null) } } } @@ -98,8 +95,7 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -114,8 +110,7 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = selectedIndex, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -173,7 +168,6 @@ class LibraryLocalTracksViewModel( duration = durationMs, album = album, coverBytes = coverBytes, - url = path, - ) + url = path) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt index 882c5cd5..1ca25812 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt @@ -24,8 +24,7 @@ import kotlinx.coroutines.flow.map class LocalMediaFoldersConfig( private val settingsRepository: SettingsRepository, - private val paths: Paths, -) { + private val paths: Paths) { val configuredRootsFlow: Flow> = settingsRepository.userSettings.map { settings -> val cacheRoot = if (settings.enableMusicCaching) { listOf(settings.cacheFolder ?: paths.getMusicCacheDirPath()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt index 60b03e58..19e393ec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt @@ -35,8 +35,7 @@ import org.koin.core.component.KoinComponent class LocalMediaLibraryCoordinator( private val discoveryService: LocalMediaDiscoveryService, private val cacheRepository: LocalMediaCacheRepository, - private val foldersConfig: LocalMediaFoldersConfig, -) : KoinComponent { + private val foldersConfig: LocalMediaFoldersConfig) : KoinComponent { private val logger by injectLogger() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -100,8 +99,7 @@ class LocalMediaLibraryCoordinator( }.onSuccess { folders -> val cache = LocalMediaCache( folders = folders, - indexedAtEpochMs = Clock.System.now().toEpochMilliseconds(), - ) + indexedAtEpochMs = Clock.System.now().toEpochMilliseconds()) cacheRepository.save(cache) _cacheState.value = cache }.onFailure { throwable -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt index d8264f6a..95820144 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt @@ -26,15 +26,13 @@ data class LocalMediaTrack( val artists: List, val durationMs: Long, val album: String? = null, - val coverBytes: ByteArray? = null, -) + val coverBytes: ByteArray? = null) @Serializable data class LocalMediaFolder( val path: String, val name: String, - val tracks: List, -) { + val tracks: List) { val trackCount: Int get() = tracks.size } @@ -42,5 +40,4 @@ data class LocalMediaFolder( @Serializable data class LocalMediaCache( val folders: List = emptyList(), - val indexedAtEpochMs: Long = 0, -) + val indexedAtEpochMs: Long = 0) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt index b9fef9c4..72065609 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt @@ -21,8 +21,7 @@ import androidx.compose.runtime.Composable data class LocalMediaPermissionState( val isGranted: Boolean, - val requestPermission: () -> Unit, -) + val requestPermission: () -> Unit) @Composable expect fun rememberLocalMediaPermissionState(): LocalMediaPermissionState diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt index 0ad5288a..7dff7fc1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt @@ -24,8 +24,7 @@ class NoopLocalMediaDiscoveryService : LocalMediaDiscoveryService { override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { return null } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt index c504d0b8..3fc8122c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.library.playlist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,13 +37,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,6 +51,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,15 +60,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddToPlaylistPicker( visible: Boolean, @@ -69,8 +75,7 @@ fun AddToPlaylistPicker( onPlaylistSelected: (String) -> Unit, modifier: Modifier = Modifier, libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f, -) { + breakpointDp: Float = 600f) { if (!visible) return val adaptiveInfo = currentWindowAdaptiveInfo() @@ -80,6 +85,13 @@ fun AddToPlaylistPicker( var isLoading by remember { mutableStateOf(true) } var searchQuery by remember { mutableStateOf("") } val scope = rememberCoroutineScope() + val searchState = rememberTextFieldState(searchQuery) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect { searchQuery = it } + } LaunchedEffect(visible, currentUserId) { if (visible) { @@ -107,21 +119,21 @@ fun AddToPlaylistPicker( val content: @Composable () -> Unit = { Column(modifier = Modifier.fillMaxSize()) { - TextField( - value = searchQuery, - onValueChange = { searchQuery = it }, + Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 12.dp), - placeholder = { Text("Search your playlists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search") + MillenniumTextField( + state = searchState, + label = "Search", + modifier = Modifier.weight(1f), + placeholder = "Search your playlists...") + } PlaylistPickerContent( playlists = filteredPlaylists, @@ -129,44 +141,36 @@ fun AddToPlaylistPicker( onPlaylistSelected = { playlistId -> onPlaylistSelected(playlistId) onDismiss() - }, - ) + }) } } if (isLargeScreen) { - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text("Add to Your Playlist") - }, - text = { - Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { - content() - } - }, - confirmButton = { - OutlineButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) + title = "Add to Your Playlist", + modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { + content() + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text("Cancel") + } + } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { + .padding(horizontal = 16.dp, vertical = 8.dp)) { Text( text = "Add to Your Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) + style = AppTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp)) content() } } @@ -177,8 +181,7 @@ fun AddToPlaylistPicker( private fun PlaylistPickerContent( playlists: List, isLoading: Boolean, - onPlaylistSelected: (String) -> Unit, -) { + onPlaylistSelected: (String) -> Unit) { if (isLoading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Loading your playlists...") @@ -190,13 +193,11 @@ private fun PlaylistPickerContent( } else { LazyColumn( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { items(playlists, key = { it.id }) { playlist -> PlaylistPickerRow( playlist = playlist, - onClick = { onPlaylistSelected(playlist.id) }, - ) + onClick = { onPlaylistSelected(playlist.id) }) } } } @@ -205,8 +206,7 @@ private fun PlaylistPickerContent( @Composable private fun PlaylistPickerRow( playlist: MetadataPlaylist, - onClick: () -> Unit, -) { + onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -214,8 +214,7 @@ private fun PlaylistPickerRow( .clickable(onClick = onClick) .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { val imageUrl = playlist.thumbnails.firstOrNull()?.url if (imageUrl != null) { AsyncImage( @@ -224,34 +223,29 @@ private fun PlaylistPickerRow( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Box( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = playlist.title.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) } } Column(modifier = Modifier.weight(1f)) { Text( text = playlist.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = "${playlist.trackCount} tracks", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 1d18c751..779e1df8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -17,6 +17,11 @@ package dev.krtirtho.spotube.modules.library.playlist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,8 +37,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,10 +49,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard @@ -57,6 +61,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @@ -64,12 +69,17 @@ import org.koin.compose.viewmodel.koinViewModel fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, searchMode: Boolean = false, - viewModel: LibraryPlaylistsViewModel = koinViewModel(), -) { + viewModel: LibraryPlaylistsViewModel = koinViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var showCreatePlaylist by remember { mutableStateOf(false) } + val searchState = rememberTextFieldState((state as? LibraryPlaylistsState.Data)?.query ?: "") + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } @@ -94,35 +104,21 @@ fun LibraryPlaylistsScreen( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { AnimatedVisibility( visible = searchMode, - modifier = Modifier.weight(1f), - ) { - TextField( - value = (state as? LibraryPlaylistsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, + modifier = Modifier.weight(1f)) { + MillenniumTextField( + state = searchState, + label = "Search", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved playlists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - } - ) + placeholder = "Search saved playlists...") } - IconButton( + MillenniumIconButton( + icon = Iconsax.IconsaxAddSquare, + contentDescription = "Create Playlist", onClick = { showCreatePlaylist = true }, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Create Playlist", - ) - } + modifier = Modifier.size(48.dp)) } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -143,8 +139,7 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -154,8 +149,7 @@ fun LibraryPlaylistsScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ErrorDisplay( errorMessage = (state as LibraryPlaylistsState.Error).message, - onRetry = { viewModel.refresh() }, - ) + onRetry = { viewModel.refresh() }) } } @@ -201,8 +195,7 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -221,9 +214,7 @@ fun LibraryPlaylistsScreen( isPublic = data.isPublic, isCollaborating = data.isCollaborating, imageBase64 = data.imageBase64, - trackIds = emptyList(), - ) - }, - ) + trackIds = emptyList()) + }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 97f580bc..efceaded 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -46,8 +46,7 @@ sealed interface LibraryPlaylistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { @@ -61,15 +60,13 @@ sealed interface LibraryPlaylistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, - ) + nextPagination = nextPagination) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { @@ -87,8 +84,7 @@ sealed interface LibraryPlaylistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryPlaylistsViewModel( - private val repository: LibraryRepository, -) : ViewModel(), KoinComponent { + private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryPlaylistsState.Loading) @@ -113,8 +109,7 @@ class LibraryPlaylistsViewModel( val result = repository.savedPlaylists() _state.value = LibraryPlaylistsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load playlists" } _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") @@ -128,8 +123,7 @@ class LibraryPlaylistsViewModel( _state.value = LibraryPlaylistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more playlists" } @@ -159,8 +153,7 @@ class LibraryPlaylistsViewModel( isPublic: Boolean, isCollaborating: Boolean, imageBase64: String, - trackIds: List, - ) { + trackIds: List) { viewModelScope.launch { runCatching { repository.createPlaylist( @@ -169,8 +162,7 @@ class LibraryPlaylistsViewModel( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) }.onFailure { e -> logger.e(e) { "Failed to create playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index b08c095c..ba7f9e85 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -18,6 +18,12 @@ package dev.krtirtho.spotube.modules.library.playlist import androidx.compose.foundation.clickable +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,32 +36,33 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.Toggle import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString @@ -66,10 +73,8 @@ data class PlaylistFormData( val isPublic: Boolean = true, val isCollaborating: Boolean = false, val imageBase64: String = "", - val imagePreviewUrl: String? = null, -) + val imagePreviewUrl: String? = null) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaylistFormSheet( visible: Boolean, @@ -78,8 +83,7 @@ fun PlaylistFormSheet( modifier: Modifier = Modifier, initialData: PlaylistFormData? = null, isEditing: Boolean = false, - breakpointDp: Float = 600f, -) { + breakpointDp: Float = 600f) { if (!visible) return val adaptiveInfo = currentWindowAdaptiveInfo() @@ -92,6 +96,20 @@ fun PlaylistFormSheet( var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } + val nameState = rememberTextFieldState(initialData?.name ?: "") + val descriptionState = rememberTextFieldState(initialData?.description ?: "") + + LaunchedEffect(nameState) { + snapshotFlow { nameState.text.toString() } + .drop(1) + .collect { name = it } + } + LaunchedEffect(descriptionState) { + snapshotFlow { descriptionState.text.toString() } + .drop(1) + .collect { description = it } + } + val scope = rememberCoroutineScope() val imagePicker = rememberFilePickerLauncher( type = FileKitType.Image @@ -114,93 +132,78 @@ fun PlaylistFormSheet( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - ) + imagePreviewUrl = imagePreviewUrl) val handleImagePick = { imagePicker.launch() } if (isLargeScreen) { - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text(if (isEditing) "Edit Playlist" else "Create Playlist") - }, - text = { - PlaylistFormFields( - name = name, - onNameChange = { name = it }, - description = description, - onDescriptionChange = { description = it }, - isPublic = isPublic, - onIsPublicChange = { isPublic = it }, - isCollaborating = isCollaborating, - onIsCollaboratingChange = { isCollaborating = it }, - imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick, - ) - }, - confirmButton = { - PrimaryButton( + title = if (isEditing) "Edit Playlist" else "Create Playlist", + modifier = modifier) { + PlaylistFormFields( + nameState = nameState, + descriptionState = descriptionState, + isPublic = isPublic, + onIsPublicChange = { isPublic = it }, + isCollaborating = isCollaborating, + onIsCollaboratingChange = { isCollaborating = it }, + imageBase64 = imageBase64, + imagePreviewUrl = imagePreviewUrl, + onPickImage = handleImagePick) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank(), - ) { + enabled = name.isNotBlank()) { Text(if (isEditing) "Save" else "Create") } - }, - dismissButton = { - OutlineButton(onClick = onDismiss) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Cancel") } - }, - ) + } + } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { + .padding(horizontal = 16.dp, vertical = 8.dp)) { Text( text = if (isEditing) "Edit Playlist" else "Create Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) + style = AppTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp)) PlaylistFormFields( - name = name, - onNameChange = { name = it }, - description = description, - onDescriptionChange = { description = it }, + nameState = nameState, + descriptionState = descriptionState, isPublic = isPublic, onIsPublicChange = { isPublic = it }, isCollaborating = isCollaborating, onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick, - ) + onPickImage = handleImagePick) Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - OutlineButton(onClick = onDismiss) { + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Cancel") } - PrimaryButton( + MillenniumButton( onClick = { onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank(), - ) { + enabled = name.isNotBlank()) { Text(if (isEditing) "Save" else "Create") } } @@ -212,41 +215,31 @@ fun PlaylistFormSheet( @Composable private fun PlaylistFormFields( - name: String, - onNameChange: (String) -> Unit, - description: String, - onDescriptionChange: (String) -> Unit, + nameState: TextFieldState, + descriptionState: TextFieldState, isPublic: Boolean, onIsPublicChange: (Boolean) -> Unit, isCollaborating: Boolean, onIsCollaboratingChange: (Boolean) -> Unit, imageBase64: String, imagePreviewUrl: String?, - onPickImage: () -> Unit, -) { + onPickImage: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextField( - value = name, - onValueChange = onNameChange, + verticalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumTextField( + state = nameState, + label = "Name", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Playlist name") }, - singleLine = true, - label = { Text("Name") }, - ) + placeholder = "Playlist name") - TextField( - value = description, - onValueChange = onDescriptionChange, + MillenniumTextField( + state = descriptionState, + label = "Description", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Description (optional)") }, - maxLines = 3, - label = { Text("Description") }, - ) + placeholder = "Description (optional)") Row( modifier = Modifier @@ -255,17 +248,15 @@ private fun PlaylistFormFields( .clickable { onIsPublicChange(!isPublic) } .padding(horizontal = 4.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Public", style = MaterialTheme.typography.bodyLarge) + Text("Public", style = AppTheme.typography.bodyLarge) Text( "Anyone can see this playlist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - Toggle(checked = isPublic, onCheckedChange = onIsPublicChange) + MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange, label = "Public") } Row( @@ -275,58 +266,50 @@ private fun PlaylistFormFields( .clickable { onIsCollaboratingChange(!isCollaborating) } .padding(horizontal = 4.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Collaborative", style = MaterialTheme.typography.bodyLarge) + Text("Collaborative", style = AppTheme.typography.bodyLarge) Text( "Others can add tracks to this playlist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - Toggle(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) + MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange, label = "Collaborative") } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Cover Image", style = MaterialTheme.typography.bodyLarge) + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Cover Image", style = AppTheme.typography.bodyLarge) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { when { imagePreviewUrl != null -> AsyncImage( model = imagePreviewUrl, contentDescription = "Playlist cover", modifier = Modifier.size(64.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) imageBase64.isNotBlank() -> { Text( "✓", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.titleMedium, + color = AppTheme.colors.primary) } else -> { Text( "No image", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } } } - PrimaryButton(onClick = onPickImage) { + MillenniumButton(onClick = onPickImage) { Text(if (imageBase64.isNotBlank() || imagePreviewUrl != null) "Change Image" else "Pick Image") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index 6797df65..5d98d25b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -17,7 +17,13 @@ package dev.krtirtho.spotube.modules.lyrics + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,17 +34,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -47,6 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope 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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -54,7 +59,7 @@ import androidx.compose.ui.unit.sp import compose.icons.FeatherIcons import compose.icons.feathericons.X import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 @@ -65,60 +70,56 @@ import org.koin.compose.viewmodel.koinViewModel fun LyricsScreen( viewModel: LyricsViewModel = koinViewModel(), modifier: Modifier = Modifier, - onClose: (() -> Unit)? = null, -) { + onClose: (() -> Unit)? = null) { val uiState by viewModel.uiState.collectAsState() val positionMillis by viewModel.positionMillisFlow.collectAsState() val scope = rememberCoroutineScope() - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = onClose == null, - title = { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - ) { - if (onClose != null) { - IconButton( - onClick = onClose, - modifier = Modifier.align(Alignment.CenterStart) - ) { - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = "Close" - ) - } - } - Row( - modifier = Modifier - .align(Alignment.Center) - .clip(RoundedCornerShape(50.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(10.dp, 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LyricsModeTab( - label = "Synced", - isSelected = uiState.mode == LyricType.SYNCED, - onClick = { viewModel.setMode(LyricType.SYNCED) } - ) - LyricsModeTab( - label = "Plain", - isSelected = uiState.mode == LyricType.STATIC, - onClick = { viewModel.setMode(LyricType.STATIC) } - ) - } - } + Column(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically) { + if (onClose != null) { + MillenniumIconButton( + onClick = onClose, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = "Close" + ) } - ) - }, - modifier = modifier, - ) { innerPadding -> + } else { + ApplicationBackButton() + } + Spacer(Modifier.weight(1f)) + Row( + modifier = Modifier + .clip(RoundedCornerShape(50.dp)) + .background(AppTheme.colors.surfaceVariant) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + LyricsModeTab( + label = "Synced", + isSelected = uiState.mode == LyricType.SYNCED, + onClick = { viewModel.setMode(LyricType.SYNCED) } + ) + LyricsModeTab( + label = "Plain", + isSelected = uiState.mode == LyricType.STATIC, + onClick = { viewModel.setMode(LyricType.STATIC) } + ) + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(44.dp)) + } Column( modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + .weight(1f) + .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = LocalAppShellBottomInset.current + 24.dp) ) { @@ -129,7 +130,7 @@ fun LyricsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + MillenniumIndeterminateProgress(modifier = Modifier.fillMaxWidth(0.4f)) } } @@ -140,8 +141,8 @@ fun LyricsScreen( ) { Text( text = uiState.error ?: "Unknown error", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.error, textAlign = TextAlign.Center ) } @@ -171,27 +172,29 @@ fun LyricsScreen( private fun LyricsModeTab( label: String, isSelected: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = RoundedCornerShape(20.dp), - color = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surface - }, - contentColor = if (isSelected) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface - } + onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50.dp)) + .background( + if (isSelected) { + AppTheme.colors.primary + } else { + Color.Transparent + } + ) + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 7.dp) ) { Text( text = label, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (isSelected) { + AppTheme.colors.onPrimary + } else { + AppTheme.colors.onSurfaceVariant + } ) } } @@ -200,8 +203,7 @@ private fun LyricsModeTab( private fun SyncedLyricsContent( syncedLyrics: List, currentIndex: Int, - onScrollToIndex: (Int) -> Unit, -) { + onScrollToIndex: (Int) -> Unit) { if (syncedLyrics.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), @@ -209,8 +211,8 @@ private fun SyncedLyricsContent( ) { Text( text = "No synced lyrics available", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center ) } @@ -236,29 +238,58 @@ private fun SyncedLyricsContent( ) { itemsIndexed(syncedLyrics) { index, line -> val isCurrentLine = index == currentIndex - Text( - text = line.text.ifBlank { "..." }, + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal, + .clip(RoundedCornerShape(10.dp)) + .then( + if (isCurrentLine) { + Modifier.background(AppTheme.colors.primaryContainer) + } else { + Modifier + } + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = formatLyricTime(line.time), + style = AppTheme.typography.labelSmall, color = if (isCurrentLine) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ), - textAlign = TextAlign.Center - ) + AppTheme.colors.onSurfaceVariant + }, + modifier = Modifier.width(44.dp) + ) + Text( + text = line.text.ifBlank { "..." }, + modifier = Modifier.weight(1f), + style = AppTheme.typography.bodyLarge.copy( + fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal, + color = if (isCurrentLine) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurface + } + ) + ) + } } } } +private fun formatLyricTime(millis: Long): String { + val totalSeconds = (millis / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} + @Composable private fun PlainLyricsContent( - plainLyrics: String, -) { + plainLyrics: String) { if (plainLyrics.isBlank()) { Box( modifier = Modifier.fillMaxSize(), @@ -266,8 +297,8 @@ private fun PlainLyricsContent( ) { Text( text = "No plain lyrics available", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center ) } @@ -281,8 +312,8 @@ private fun PlainLyricsContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 30.sp), - color = MaterialTheme.colorScheme.onSurface, + style = AppTheme.typography.bodyLarge.copy(lineHeight = 30.sp), + color = AppTheme.colors.onSurface, textAlign = TextAlign.Center ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt index c1615daa..a38c3043 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt @@ -39,20 +39,17 @@ enum class LyricType { SYNCED } - data class LyricsUiState( val mode: LyricType = LyricType.SYNCED, val syncedLyrics: List? = null, val plainLyrics: String? = null, val isLoading: Boolean = false, - val error: String? = null, -) + val error: String? = null) class LyricsViewModel( private val pluginManager: PluginManager, audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { + private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val _uiState = MutableStateFlow(LyricsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -90,8 +87,7 @@ class LyricsViewModel( it.copy( syncedLyrics = lyrics?.syncedLyrics, plainLyrics = lyrics?.plainLyrics, - isLoading = false, - ) + isLoading = false) } } } ?: run { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index 1696ad32..b50e255f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -17,6 +17,16 @@ package dev.krtirtho.spotube.modules.playlist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,14 +39,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,6 +53,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,20 +62,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddTracksToPlaylistDialog( visible: Boolean, @@ -74,8 +80,7 @@ fun AddTracksToPlaylistDialog( modifier: Modifier = Modifier, searchRepository: SearchRepository = koinInject(), libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f, -) { + breakpointDp: Float = 600f) { if (!visible) return val adaptiveInfo = currentWindowAdaptiveInfo() @@ -88,6 +93,13 @@ fun AddTracksToPlaylistDialog( var addingTrackIds by remember { mutableStateOf>(emptySet()) } var addedTrackIds by remember { mutableStateOf>(emptySet()) } val scope = rememberCoroutineScope() + val queryState = rememberTextFieldState(query) + + LaunchedEffect(queryState) { + snapshotFlow { queryState.text.toString() } + .drop(1) + .collect { query = it } + } LaunchedEffect(visible) { if (!visible) { @@ -125,32 +137,21 @@ fun AddTracksToPlaylistDialog( val content: @Composable () -> Unit = { Column( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = query, - onValueChange = { query = it }, + verticalAlignment = Alignment.CenterVertically) { + MillenniumTextField( + state = queryState, + label = "Search", modifier = Modifier.weight(1f), - placeholder = { Text("Search tracks...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) - IconButton(onClick = handleSearch) { - Icon( - imageVector = Iconsax.IconsaxSearch, - contentDescription = "Search", - ) - } + placeholder = "Search tracks...") + MillenniumIconButton( + icon = Iconsax.IconsaxSearch, + contentDescription = "Search", + onClick = handleSearch, + style = MillenniumIconButtonStyle.Secondary) } when { @@ -159,9 +160,8 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() + contentAlignment = Alignment.Center) { + MillenniumIndeterminateProgress() } } hasSearched && searchResults.isEmpty() -> { @@ -169,8 +169,7 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text("No tracks found") } } @@ -179,15 +178,13 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { items(searchResults, key = { it.id }) { track -> TrackSearchResultRow( track = track, isAdding = addingTrackIds.contains(track.id), isAdded = addedTrackIds.contains(track.id), - onAdd = { handleAddTrack(track) }, - ) + onAdd = { handleAddTrack(track) }) } } } @@ -196,8 +193,7 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text("Search for tracks to add") } } @@ -206,39 +202,30 @@ fun AddTracksToPlaylistDialog( } if (isLargeScreen) { - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text("Add Tracks to Playlist") - }, - text = { - Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { - content() - } - }, - confirmButton = { - PrimaryButton(onClick = onDismiss) { - Text("Done") - } - }, - ) + title = "Add Tracks to Playlist", + modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { + content() + } + MillenniumButton(onClick = onDismiss) { + Text("Done") + } + } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) - .height(600.dp), - ) { + .height(600.dp)) { Text( text = "Add Tracks to Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) + style = AppTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp)) content() } } @@ -250,16 +237,14 @@ private fun TrackSearchResultRow( track: MetadataTrack, isAdding: Boolean, isAdded: Boolean, - onAdd: () -> Unit, -) { + onAdd: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { val imageUrl = track.album?.thumbnails?.firstOrNull()?.url if (imageUrl != null) { AsyncImage( @@ -268,55 +253,49 @@ private fun TrackSearchResultRow( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(6.dp)), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Box( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = track.title.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) } } Column(modifier = Modifier.weight(1f)) { Text( text = track.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = track.artists.joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } when { isAdded -> { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxCheckCircle, contentDescription = "Added", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) + tint = AppTheme.colors.primary, + modifier = Modifier.size(24.dp)) } isAdding -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) } else -> { - OutlineButton(onClick = onAdd) { + MillenniumButton( + onClick = onAdd, + style = MillenniumButtonStyle.Secondary) { Text("Add") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt index c7d444f6..c58da096 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt @@ -37,8 +37,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class PlaylistRepository( val pluginManager: PluginManager, - private val libraryRepository: LibraryRepository, -) { + private val libraryRepository: LibraryRepository) { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val plugin diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index a472ac93..09ae39bb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -17,12 +17,17 @@ package dev.krtirtho.spotube.modules.playlist + +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,7 +45,6 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData @@ -90,14 +94,18 @@ fun PlaylistScreen( modifier = Modifier .fillMaxWidth() .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.Center, - ) { - OutlineButton(onClick = { showAddTracksDialog = true }) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - ) - Text("Add Tracks", modifier = Modifier.padding(start = 8.dp)) + horizontalArrangement = Arrangement.Center) { + MillenniumButton( + onClick = { showAddTracksDialog = true }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = null) + Text("Add Tracks") + } } } } @@ -153,8 +161,7 @@ fun PlaylistScreen( description = it.description.orEmpty(), isPublic = true, isCollaborating = false, - imagePreviewUrl = it.thumbnails.firstOrNull()?.url, - ) + imagePreviewUrl = it.thumbnails.firstOrNull()?.url) }, onSubmit = { data -> viewModel.updatePlaylist( @@ -162,17 +169,14 @@ fun PlaylistScreen( description = data.description.ifBlank { null }, isPublic = data.isPublic, isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64.ifBlank { null }, - ) - }, - ) + imageBase64 = data.imageBase64.ifBlank { null }) + }) AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) AddTracksToPlaylistDialog( visible = showAddTracksDialog, @@ -180,8 +184,6 @@ fun PlaylistScreen( onDismiss = { showAddTracksDialog = false viewModel.refresh() - }, - ) - }, - ) + }) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 5a4c1076..ec441718 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -66,15 +66,13 @@ sealed interface PlaylistScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data { + override val isSaving: Boolean = false) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( playlist = playlist, isSaved = isSaved, tracks = tracks, nextPagination = nextPagination, - isSaving = isSaving, - ) + isSaving = isSaving) } data class LoadingMore( @@ -82,8 +80,7 @@ sealed interface PlaylistScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data + override val isSaving: Boolean = false) : Data } data class Error(val message: String) : PlaylistScreenState @@ -100,8 +97,7 @@ class PlaylistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(PlaylistScreenState.Loading) @@ -127,8 +123,7 @@ class PlaylistViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -136,8 +131,7 @@ class PlaylistViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -177,8 +171,7 @@ class PlaylistViewModel( playlist = playlistInfo?.first, isSaved = playlistInfo?.second ?: false, tracks = tracksResult?.items ?: emptyList(), - nextPagination = tracksResult?.nextPagination, - ) + nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load playlist" } _state.value = PlaylistScreenState.Error(e.message ?: "Unknown error") @@ -196,8 +189,7 @@ class PlaylistViewModel( isSaved = currentState.isSaved, tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - isSaving = currentState.isSaving, - ) + isSaving = currentState.isSaving) } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } @@ -251,8 +243,7 @@ class PlaylistViewModel( type = RemoteCollectionType.Playlist, id = playlistId, title = title, - startTrack = track, - ) + startTrack = track) } fun refresh() { @@ -267,8 +258,7 @@ class PlaylistViewModel( description: String?, isPublic: Boolean?, isCollaborating: Boolean?, - imageBase64: String?, - ) { + imageBase64: String?) { viewModelScope.launch { runCatching { libraryRepository.updatePlaylist( @@ -278,8 +268,7 @@ class PlaylistViewModel( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = null, - ) + trackIds = null) }.onFailure { e -> logger.e(e) { "Failed to update playlist" } }.onSuccess { @@ -352,8 +341,7 @@ class PlaylistViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt index b43b5c52..532bc9ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt @@ -36,8 +36,7 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry( contact = "", repository = "", bugs = "", - license = "", -) + license = "") val LRCLIB_BUILT_IN_PLUGIN = PluginEntry( name = "LRCLib Lyrics", version = "0.1.0", @@ -45,18 +44,15 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry( description = "LRCLib Lyrics plugin for fetching lyrics.", author = "Spotube Team", capabilities = listOf( - PluginCapability.NETWORK_REQUESTS, - ), + PluginCapability.NETWORK_REQUESTS), abilities = listOf(PluginAbility.LYRICS), contact = "", repository = "", bugs = "", - license = "", -) + license = "") val BUILT_IN_PLUGINS = listOf( NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - LRCLIB_BUILT_IN_PLUGIN, -) + LRCLIB_BUILT_IN_PLUGIN) /** * The bundled placeholder logo for a built-in plugin, or `null` if it has none. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt index 91ffc9b3..86cfe2a1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt @@ -33,8 +33,7 @@ import kotlinx.serialization.json.Json data class GitHubRepoSearchResponse( @SerialName("total_count") val totalCount: Int, @SerialName("incomplete_results") val incompleteResults: Boolean, - val items: List, -) + val items: List) @Serializable data class GitHubRepo( @@ -43,14 +42,12 @@ data class GitHubRepo( val description: String? = null, @SerialName("stargazers_count") val stargazersCount: Int = 0, @SerialName("html_url") val htmlUrl: String, - val owner: GitHubOwner, -) + val owner: GitHubOwner) @Serializable data class GitHubOwner( val login: String, - @SerialName("avatar_url") val avatarUrl: String, -) + @SerialName("avatar_url") val avatarUrl: String) @Serializable data class GitHubRelease( @@ -60,14 +57,12 @@ data class GitHubRelease( val assets: List, @SerialName("html_url") val htmlUrl: String, val prerelease: Boolean = false, - val draft: Boolean = false, -) + val draft: Boolean = false) @Serializable data class GitHubAsset( val name: String, - @SerialName("browser_download_url") val browserDownloadUrl: String, -) + @SerialName("browser_download_url") val browserDownloadUrl: String) class GitHubPluginRepository { private val httpClient = HttpClient { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index dfc636e7..5d13734d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -83,11 +83,9 @@ interface ScrobblePluginSource { val selectedScrobblePlugin: StateFlow } - class PluginManager( val database: Database, - val paths: Paths, -) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { + val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -97,7 +95,6 @@ class PluginManager( val pluginsDirPath: Path get() = pluginsDir private val httpClient = HttpClient() - enum class InstallPromptKind { INSTALL, UPDATE, @@ -113,8 +110,7 @@ class PluginManager( val message: String, val bytes: ByteArray? = null, val existingEntry: PluginEntry? = null, - val confirmLabel: String? = null, - ) + val confirmLabel: String? = null) val pendingPlugin = MutableStateFlow(null) @@ -122,8 +118,7 @@ class PluginManager( .map { p -> val json = p[DatabaseKeys.PLUGINS_STATE_KEY] val defaultSelectedPlugins = mapOf( - PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - ) + PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN) if (json == null) { PluginManagerStates.Data( plugins = BUILT_IN_PLUGINS, @@ -156,8 +151,7 @@ class PluginManager( res.copy( plugins = resolvedPlugins, - selectedPlugins = selectedPlugins, - ) + selectedPlugins = selectedPlugins) } catch (_: Exception) { PluginManagerStates.Data( BUILT_IN_PLUGINS, @@ -202,8 +196,7 @@ class PluginManager( val service = ZiplinePluginService( applicationName = plugin.name, manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}", - pluginInfo = plugin, - ) + pluginInfo = plugin) ziplineServices[ability] = service ziplineServicesByPluginID[plugin.id] = service service.start() @@ -275,7 +268,6 @@ class PluginManager( override val selectedScrobblePlugin = filterSelectedPluginByType(PluginAbility.SCROBBLE) - fun launchTask(block: suspend CoroutineScope.() -> Unit): Job { return scope.launch(block = block) } @@ -288,7 +280,6 @@ class PluginManager( block() } - private suspend fun updatePluginsState(newState: PluginManagerStates.Data) { database.settingsDataStore.updateData { preferences -> preferences.toMutablePreferences().apply { @@ -397,8 +388,7 @@ class PluginManager( suspend fun addPluginFromByteArray( bytes: ByteArray, - allowReplacingInstalled: Boolean = false, - ) { + allowReplacingInstalled: Boolean = false) { withContext(Dispatchers.IO) { if (!FileSystem.SYSTEM.exists(pluginsDir)) { FileSystem.SYSTEM.createDirectories(pluginsDir) @@ -606,8 +596,7 @@ class PluginManager( entry = pluginEntry, kind = InstallPromptKind.INFO, title = "Plugin API not compatible", - message = apiError, - ) + message = apiError) } val installedPlugin = findInstalledPlugin(pluginEntry.id) ?: return PendingPlugin( @@ -616,8 +605,7 @@ class PluginManager( title = "Install plugin?", message = "${pluginEntry.name} will be added to your installed plugins.", bytes = bytes, - confirmLabel = "Install", - ) + confirmLabel = "Install") return when (compareVersions(installedPlugin, pluginEntry)) { VersionRelation.Update -> PendingPlugin( @@ -627,8 +615,7 @@ class PluginManager( message = "An older version is installed (${installedPlugin.version}). Update to ${pluginEntry.version}?", bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Update", - ) + confirmLabel = "Update") VersionRelation.Replace -> PendingPlugin( entry = pluginEntry, @@ -637,8 +624,7 @@ class PluginManager( message = buildReplaceMessage(installedPlugin, pluginEntry), bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Replace", - ) + confirmLabel = "Replace") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt index 1d7189e4..e8ed2f06 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt @@ -55,8 +55,7 @@ data class PluginEntry( val contact: String, val repository: String, val bugs: String, - val license: String, -) { + val license: String) { @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray()) .toUInt() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index d8cc2ca5..a578caf8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -17,6 +17,20 @@ package dev.krtirtho.spotube.modules.plugin + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,18 +48,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,14 +74,6 @@ import coil3.request.ImageRequest import coil3.request.crossfade import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.core.extras.kebabToTitleCase -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem @@ -100,6 +105,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxLink import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import okio.Path +import kotlinx.coroutines.flow.collect import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res @@ -139,37 +145,30 @@ private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") private val VERIFIED_PLUGIN_OWNERS = setOf() -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PluginScreen( viewModel: PluginViewModel = koinViewModel(), - onboarding: Boolean = false, -) { - BaseScaffold( - topBar = { - if (!onboarding) { - ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) - } - }, - ) { innerPadding -> + onboarding: Boolean = false) { + Column(modifier = Modifier.fillMaxSize()) { + if (!onboarding) { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } PluginScreenContent( viewModel = viewModel, onboarding = onboarding, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } /** - * The plugin management UI without its own [BaseScaffold] / app bar, so it can be embedded + * The plugin management UI without its own scaffold / app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [PluginScreen]. */ @Composable internal fun PluginScreenContent( viewModel: PluginViewModel, onboarding: Boolean = false, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current @@ -250,7 +249,7 @@ internal fun PluginScreenContent( Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center - ) { CircularProgressIndicator() } + ) { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) } } is PluginUiState.Data -> { @@ -274,18 +273,15 @@ internal fun PluginScreenContent( horizontal = 4.dp, vertical = 16.dp ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( stringResource(Res.string.welcome_plugins_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold) Text( stringResource(Res.string.welcome_plugins_setup_guide), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } } item { @@ -293,17 +289,21 @@ internal fun PluginScreenContent( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp) ) { - OutlineButton(onClick = { viewModel.showOnboardingDiscovery(true) }) { + MillenniumButton( + onClick = { viewModel.showOnboardingDiscovery(true) }, + style = MillenniumButtonStyle.Secondary) { Text( stringResource(Res.string.welcome_plugins_discover), fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal ) } - OutlineButton(onClick = { - viewModel.showOnboardingDiscovery( - false - ) - }) { + MillenniumButton( + onClick = { + viewModel.showOnboardingDiscovery( + false + ) + }, + style = MillenniumButtonStyle.Secondary) { Text( stringResource(Res.string.welcome_plugins_sources), fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal @@ -322,8 +322,7 @@ internal fun PluginScreenContent( selections = data.abilitySelections, onSelected = { ability, plugin -> viewModel.selectPlugin(ability, plugin) - }, - ) + }) } item { @@ -336,15 +335,19 @@ internal fun PluginScreenContent( ) { Text( stringResource(Res.string.plugin_configure_title), - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) - PrimaryButton(onClick = { viewModel.showInstallSheet() }) { - Icon( - Iconsax.IconsaxAdd, - contentDescription = stringResource(Res.string.plugin_install_section_title), - ) - Text(stringResource(Res.string.plugin_install_section_title)) + MillenniumButton(onClick = { viewModel.showInstallSheet() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = stringResource(Res.string.plugin_install_section_title)) + Text(stringResource(Res.string.plugin_install_section_title)) + } } } } @@ -360,29 +363,29 @@ internal fun PluginScreenContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Surface( + Box( modifier = Modifier.size(72.dp) - .clip(RoundedCornerShape(18.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) ) { Box(contentAlignment = Alignment.Center) { - Icon( + MillenniumIcon( Iconsax.IconsaxBox, contentDescription = null, modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) } } Text( stringResource(Res.string.plugin_empty_title), - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) Text( stringResource(Res.string.plugin_empty_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant ) } } @@ -400,8 +403,8 @@ internal fun PluginScreenContent( data.plugins.size, noun ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding( horizontal = 4.dp, vertical = 4.dp @@ -409,21 +412,22 @@ internal fun PluginScreenContent( ) } item { - Card( + Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - ) { + .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp)) { Column( modifier = Modifier.fillMaxWidth() ) { data.plugins.forEachIndexed { index, item -> if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) + MillenniumSeparator() } PluginCard( plugin = item.plugin, @@ -440,8 +444,7 @@ internal fun PluginScreenContent( } else null, onLogout = if (item.isSelected && item.authState.requiresAuth) { { viewModel.logout(item.plugin) } - } else null, - ) + } else null) } } } @@ -452,7 +455,9 @@ internal fun PluginScreenContent( if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) { if (onboarding) { item { - OutlineButton(onClick = viewModel::showInstallSheet) { + MillenniumButton( + onClick = viewModel::showInstallSheet, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.plugin_install_section_title)) } } @@ -465,15 +470,15 @@ internal fun PluginScreenContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxGlobe, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) Text( stringResource(Res.string.welcome_plugins_discover), - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) } @@ -484,7 +489,7 @@ internal fun PluginScreenContent( Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(Modifier.size(24.dp)) + MillenniumIndeterminateProgress(Modifier.widthIn(max = 120.dp)) } } } @@ -492,7 +497,7 @@ internal fun PluginScreenContent( item { Text( stringResource(Res.string.welcome_plugins_discovery_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(12.dp) ) } @@ -516,7 +521,7 @@ internal fun PluginScreenContent( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 120.dp)) } } } @@ -526,14 +531,16 @@ internal fun PluginScreenContent( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( stringResource(Res.string.welcome_plugins_discovery_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.error, modifier = Modifier.padding( horizontal = 4.dp, vertical = 8.dp ) ) - OutlineButton(onClick = viewModel::retryDiscovery) { + MillenniumButton( + onClick = viewModel::retryDiscovery, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.welcome_plugins_retry)) } } @@ -541,7 +548,9 @@ internal fun PluginScreenContent( } if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) { item { - OutlineButton(onClick = viewModel::loadNextDiscoverPage) { + MillenniumButton( + onClick = viewModel::loadNextDiscoverPage, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.welcome_plugins_more)) } } @@ -590,8 +599,7 @@ private fun InstallSheet( onUrlChange: (String) -> Unit, onSubmitUrl: () -> Unit, onPickFile: () -> Unit, - onDismiss: () -> Unit, -) { + onDismiss: () -> Unit) { AdaptiveDialogBottomSheet( onDismiss = onDismiss, title = { @@ -599,95 +607,78 @@ private fun InstallSheet( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxImportArrow2Bulk, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) Text( stringResource(Res.string.plugin_install_section_title), - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) } - }, - ) { + }) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - stringResource(Res.string.plugin_section_url_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + val urlState = rememberTextFieldState(urlInput) + LaunchedEffect(urlInput) { + if (urlState.text.toString() != urlInput) { + urlState.edit { replace(0, length, urlInput) } + } + } + LaunchedEffect(urlState) { + snapshotFlow { urlState.text.toString() }.collect { value -> + if (value != urlInput) onUrlChange(value) + } + } + Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - TextField( - value = urlInput, - onValueChange = onUrlChange, + MillenniumTextField( + state = urlState, + label = stringResource(Res.string.plugin_section_url_title), modifier = Modifier.weight(1f), - placeholder = { - Text( - stringResource(Res.string.plugin_url_placeholder), - style = MaterialTheme.typography.bodySmall - ) - }, - leadingIcon = { - Icon( - Iconsax.IconsaxLink, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - isError = urlError != null, - singleLine = true, - ) - SecondaryIconButton( + placeholder = stringResource(Res.string.plugin_url_placeholder), + error = urlError?.text()) + MillenniumIconButton( onClick = onSubmitUrl, enabled = !isLoadingUrl, - ) { + style = MillenniumIconButtonStyle.Secondary) { if (isLoadingUrl) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) + MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp)) } else { - Icon( + MillenniumIcon( Iconsax.IconsaxImportArrow2Bulk, - contentDescription = stringResource(Res.string.plugin_action_download), - ) + contentDescription = stringResource(Res.string.plugin_action_download)) } } } - urlError?.let { error -> - Text( - error.text(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + MillenniumSeparator() Text( stringResource(Res.string.plugin_section_file_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurfaceVariant ) - OutlineButton( + MillenniumButton( onClick = onPickFile, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Iconsax.IconsaxExportArrowBulk, - contentDescription = stringResource(Res.string.plugin_action_install_from_file) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.plugin_action_install_from_file)) + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxExportArrowBulk, + contentDescription = stringResource(Res.string.plugin_action_install_from_file) + ) + Text(stringResource(Res.string.plugin_action_install_from_file)) + } } } } @@ -697,51 +688,33 @@ private fun InstallSheet( private fun PluginInfoDialog( plugin: PluginEntry, logoPath: Path?, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - ) { - PluginLogo( - plugin = plugin, - logoPath = logoPath, - contentDescription = plugin.name, - modifier = Modifier.fillMaxSize(), - fallback = { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - }, - ) - } - Text( - plugin.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - }, - actions = { - PrimaryButton(onClick = onDismiss) { - Text("Close") - } + title = plugin.name) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) + ) { + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Box(contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = AppTheme.colors.primary + ) + } + }) } - ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth() @@ -749,8 +722,8 @@ private fun PluginInfoDialog( if (plugin.description.isNotBlank()) { Text( plugin.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface ) } @@ -792,6 +765,11 @@ private fun PluginInfoDialog( } } } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = onDismiss) { + Text("Close") + } + } } } @@ -800,37 +778,30 @@ private fun SupportDialog( plugin: PluginEntry, supportText: String?, isLoading: Boolean, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - "Support ${plugin.name}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - }, - actions = { - PrimaryButton(onClick = onDismiss) { - Text("Close") - } - } - ) { + title = "Support ${plugin.name}") { if (isLoading) { Box( modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) } } else { Text( supportText ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface ) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = onDismiss) { + Text("Close") + } + } } } @@ -838,15 +809,20 @@ private fun SupportDialog( private fun DiscoverRepoCard( repo: GitHubRepo, isInstalling: Boolean, - onInstall: () -> Unit, -) { + onInstall: () -> Unit) { val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS - Card( + Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp) ) { Row( modifier = Modifier @@ -879,35 +855,37 @@ private fun DiscoverRepoCard( .last() .replace("spotube-plugin-", "") .kebabToTitleCase(), - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) if (isOfficial) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) ) { Text( "Official", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary, modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) ) } } else if (isVerified) { - Surface( - shape = RoundedCornerShape(4.dp), - color = Color(0xFF4CAF50).copy(alpha = 0.15f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF4CAF50).copy(alpha = 0.15f)) ) { Row( modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckCircle, contentDescription = null, modifier = Modifier.size(10.dp), @@ -915,7 +893,7 @@ private fun DiscoverRepoCard( ) Text( "Verified", - style = MaterialTheme.typography.labelSmall, + style = AppTheme.typography.labelSmall, color = Color(0xFF4CAF50) ) } @@ -925,8 +903,8 @@ private fun DiscoverRepoCard( if (!repo.description.isNullOrBlank()) { Text( repo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -937,68 +915,71 @@ private fun DiscoverRepoCard( ) { Text( repo.owner.login, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxHeart, contentDescription = "Github Stars", modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( repo.stargazersCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.clickable { - openUrlInBrowser(repo.htmlUrl) - } + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.surfaceVariant) + .clickable { + openUrlInBrowser(repo.htmlUrl) + } ) { Row( modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.CarbonGithubLogo, contentDescription = "Github Repository URL", modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( "github.com", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } } } - SecondaryButton( + MillenniumButton( onClick = onInstall, - enabled = !isInstalling + enabled = !isInstalling, + style = MillenniumButtonStyle.Secondary ) { - if (isInstalling) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - Iconsax.IconsaxAdd, - contentDescription = null, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (isInstalling) { + MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp)) + } else { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = null) + } + Text(stringResource(Res.string.plugin_section_install)) } - Text(stringResource(Res.string.plugin_section_install)) } } } @@ -1012,14 +993,14 @@ private fun DetailRow(label: String, value: String) { ) { Text( "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) ) Text( value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface ) } } @@ -1032,14 +1013,14 @@ private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit ) { Text( "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) ) Text( value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -1054,20 +1035,21 @@ private fun DetailChipsRow(label: String, chips: List) { ) { Text( "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { chips.forEach { chip -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) ) { Text( chip, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) } @@ -1079,8 +1061,7 @@ private fun DetailChipsRow(label: String, chips: List) { @Composable fun DefaultAbilityPluginSelector( selection: AbilitySelection, - onSelected: (PluginEntry?) -> Unit = { }, -) { + onSelected: (PluginEntry?) -> Unit = { }) { val plugins = selection.plugins val selectedPlugin = selection.selectedPlugin val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) @@ -1093,8 +1074,7 @@ fun DefaultAbilityPluginSelector( AdaptiveMenuItem( label = plugin.name, onClick = { onSelected(plugin) }, - selected = isSelected, - ) + selected = isSelected) ) } } else { @@ -1102,8 +1082,7 @@ fun DefaultAbilityPluginSelector( AdaptiveMenuItem( label = noPluginsText, onClick = { }, - enabled = false, - ) + enabled = false) ) } } @@ -1120,11 +1099,12 @@ fun DefaultAbilityPluginSelector( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = selection.ability.accentColor().copy(alpha = 0.1f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) ) { - Icon( + MillenniumIcon( imageVector = selection.ability.abilityIcon(), contentDescription = stringResource( Res.string.settings_plugins_plugin_content_description, @@ -1141,21 +1121,21 @@ fun DefaultAbilityPluginSelector( Res.string.settings_plugins_default_ability_title, selection.ability.displayLabel() ), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface ) if (selectedPlugin != null) { Text( selectedPlugin.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, modifier = Modifier.padding(top = 4.dp) ) } else { Text( stringResource(Res.string.settings_plugins_no_selection), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp) ) } @@ -1171,13 +1151,13 @@ fun DefaultAbilityPluginSelector( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = selection.ability.accentColor().copy(alpha = 0.1f) + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) ) { - Icon( + MillenniumIcon( imageVector = selection.ability.abilityIcon(), contentDescription = null, modifier = Modifier.padding(8.dp), @@ -1190,35 +1170,37 @@ fun DefaultAbilityPluginSelector( Res.string.settings_plugins_default_ability_title, selection.ability.displayLabel() ), - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) selectedPlugin?.let { Text( it.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) } } } }, trigger = { onClick -> - OutlineButton(onClick = onClick) { - Text( - if (selectedPlugin != null) { - stringResource(Res.string.settings_plugins_action_change) - } else { - stringResource(Res.string.settings_plugins_action_select) + " " - }, - ) - Icon( - imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + if (selectedPlugin != null) { + stringResource(Res.string.settings_plugins_action_change) + } else { + stringResource(Res.string.settings_plugins_action_select) + }) + MillenniumIcon( + imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp)) + } } - }, - ) + }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt index 4bc032b3..3e04d224 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt @@ -49,22 +49,19 @@ import org.koin.core.component.KoinComponent data class PluginAuthState( val requiresAuth: Boolean = false, - val isLoggedIn: Boolean = false, -) + val isLoggedIn: Boolean = false) data class PluginListItem( val plugin: PluginEntry, val isSelected: Boolean, val authState: PluginAuthState, - val logoPath: Path?, -) + val logoPath: Path?) data class AbilitySelection( val ability: PluginAbility, val plugins: List, val selectedPlugin: PluginEntry?, - val pluginLogoPaths: Map = emptyMap(), -) + val pluginLogoPaths: Map = emptyMap()) data class PluginDiscoverState( val repos: List = emptyList(), @@ -74,8 +71,7 @@ data class PluginDiscoverState( val isLoadingMore: Boolean = false, val error: String? = null, val installingRepoId: Long? = null, - val isInitialLoaded: Boolean = false, -) + val isInitialLoaded: Boolean = false) sealed interface UrlError { data object Empty : UrlError @@ -105,8 +101,7 @@ sealed interface PluginUiState { val installDialogRepo: GitHubRepo? = null, val releases: List = emptyList(), val isLoadingReleases: Boolean = false, - val discover: PluginDiscoverState = PluginDiscoverState(), - ) : PluginUiState + val discover: PluginDiscoverState = PluginDiscoverState()) : PluginUiState } private data class PluginManagerSnapshot( @@ -116,8 +111,7 @@ private data class PluginManagerSnapshot( val audioPlugins: List, val lyricsPlugins: List, val scrobblePlugins: List, - val activeServices: Map?, -) + val activeServices: Map?) private data class PluginScreenState( val onboardingDiscover: Boolean = true, @@ -133,14 +127,12 @@ private data class PluginScreenState( val installDialogRepo: GitHubRepo? = null, val releases: List = emptyList(), val isLoadingReleases: Boolean = false, - val discover: PluginDiscoverState = PluginDiscoverState(), -) + val discover: PluginDiscoverState = PluginDiscoverState()) @OptIn(ExperimentalCoroutinesApi::class) class PluginViewModel( private val pluginManager: PluginManager, - private val webViewController: WebViewController, -) : ViewModel(), KoinComponent { + private val webViewController: WebViewController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val gitHubRepo = GitHubPluginRepository() @@ -172,8 +164,7 @@ class PluginViewModel( private class PaginationInfo( var currentPage: Int = 1, var hasMore: Boolean = true, - var totalCount: Int = 0, - ) + var totalCount: Int = 0) @Suppress("UNCHECKED_CAST") private val pluginManagerSnapshot: StateFlow = combine( @@ -183,8 +174,7 @@ class PluginViewModel( pluginManager.audioPlugins, pluginManager.lyricsPlugins, pluginManager.scrobblePlugins, - pluginManager.ziplineServices, - ) { values -> + pluginManager.ziplineServices) { values -> PluginManagerSnapshot( state = values[0] as PluginManagerStates, pendingPlugin = values[1] as PluginManager.PendingPlugin?, @@ -192,8 +182,7 @@ class PluginViewModel( audioPlugins = values[3] as List, lyricsPlugins = values[4] as List, scrobblePlugins = values[5] as List, - activeServices = values[6] as Map?, - ) + activeServices = values[6] as Map?) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), @@ -204,8 +193,7 @@ class PluginViewModel( audioPlugins = emptyList(), lyricsPlugins = emptyList(), scrobblePlugins = emptyList(), - activeServices = null, - ) + activeServices = null) ) private val pluginLogoPaths: StateFlow> = pluginManager.state @@ -237,8 +225,7 @@ class PluginViewModel( pluginManagerSnapshot, _screenState, pluginAuthStates, - pluginLogoPaths, - ) { snapshot, screen, authStates, logoPaths -> + pluginLogoPaths) { snapshot, screen, authStates, logoPaths -> when (val state = snapshot.state) { is PluginManagerStates.Loading -> PluginUiState.Loading is PluginManagerStates.Data -> PluginUiState.Data( @@ -248,35 +235,29 @@ class PluginViewModel( plugin = plugin, isSelected = state.selectedPlugins.containsValue(plugin), authState = authStates[plugin.id] ?: PluginAuthState(), - logoPath = logoPaths[plugin.id], - ) + logoPath = logoPaths[plugin.id]) }, abilitySelections = listOf( AbilitySelection( PluginAbility.METADATA, snapshot.metadataPlugins, state.selectedPlugins[PluginAbility.METADATA], - logoPaths, - ), + logoPaths), AbilitySelection( PluginAbility.AUDIO, snapshot.audioPlugins, state.selectedPlugins[PluginAbility.AUDIO], - logoPaths, - ), + logoPaths), AbilitySelection( PluginAbility.LYRICS, snapshot.lyricsPlugins, state.selectedPlugins[PluginAbility.LYRICS], - logoPaths, - ), + logoPaths), AbilitySelection( PluginAbility.SCROBBLE, snapshot.scrobblePlugins, state.selectedPlugins[PluginAbility.SCROBBLE], - logoPaths, - ), - ), + logoPaths)), pendingPlugin = snapshot.pendingPlugin, pendingPluginLogoPath = snapshot.pendingPlugin?.entry?.id?.let { getLogoPath(it) }, urlInput = screen.urlInput, @@ -291,8 +272,7 @@ class PluginViewModel( installDialogRepo = screen.installDialogRepo, releases = screen.releases, isLoadingReleases = screen.isLoadingReleases, - discover = screen.discover, - ) + discover = screen.discover) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PluginUiState.Loading) @@ -373,8 +353,7 @@ class PluginViewModel( it.copy( urlError = e.message?.let { message -> UrlError.Message(message) } ?: UrlError.DownloadFailed, - isLoadingUrl = false, - ) + isLoadingUrl = false) } } } @@ -403,8 +382,7 @@ class PluginViewModel( _screenState.update { it.copy( showPluginInfo = plugin, - pluginInfoLogoPath = getLogoPath(plugin.id), - ) + pluginInfoLogoPath = getLogoPath(plugin.id)) } } @@ -417,8 +395,7 @@ class PluginViewModel( it.copy( showPluginSupport = plugin, isLoadingSupport = true, - supportText = null, - ) + supportText = null) } viewModelScope.launch { val service = findServiceForPlugin(plugin.id) @@ -442,8 +419,7 @@ class PluginViewModel( it.copy( showPluginSupport = null, supportText = null, - isLoadingSupport = false, - ) + isLoadingSupport = false) } } @@ -452,8 +428,7 @@ class PluginViewModel( it.copy( installDialogRepo = repo, isLoadingReleases = true, - releases = emptyList(), - ) + releases = emptyList()) } viewModelScope.launch { runCatching { @@ -549,8 +524,7 @@ class PluginViewModel( currentPage = 1, hasMore = _paginationInfo.hasMore, isLoading = false, - isInitialLoaded = true, - ) + isInitialLoaded = true) ) } }.onFailure { e -> @@ -560,8 +534,7 @@ class PluginViewModel( discover = it.discover.copy( isLoading = false, error = e.message, - isInitialLoaded = true, - ) + isInitialLoaded = true) ) } } @@ -588,8 +561,7 @@ class PluginViewModel( repos = filterInstalledRepos(_allRepos), currentPage = nextPage, hasMore = _paginationInfo.hasMore, - isLoadingMore = false, - ) + isLoadingMore = false) ) } }.onFailure { e -> @@ -598,8 +570,7 @@ class PluginViewModel( it.copy( discover = it.discover.copy( isLoadingMore = false, - error = e.message, - ) + error = e.message) ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt index 9f835868..36ff939a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt @@ -17,6 +17,8 @@ package dev.krtirtho.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -32,10 +34,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -123,23 +125,19 @@ private fun PluginEntry.metadataLabel(): String = buildString { internal fun DefaultAbilityPluginGrid( selections: List, onSelected: (PluginAbility, PluginEntry?) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { selections.chunked(2).forEach { rowSelections -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { rowSelections.forEach { selection -> DefaultAbilityPluginCard( selection = selection, onSelected = { onSelected(selection.ability, it) }, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) } repeat(2 - rowSelections.size) { Spacer(modifier = Modifier.weight(1f)) @@ -153,8 +151,7 @@ internal fun DefaultAbilityPluginGrid( private fun DefaultAbilityPluginCard( selection: AbilitySelection, onSelected: (PluginEntry?) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val ability = selection.ability val selected = selection.selectedPlugin val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) @@ -172,27 +169,23 @@ private fun DefaultAbilityPluginCard( modifier = Modifier .size(30.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { PluginLogo( plugin = plugin, logoPath = selection.pluginLogoPaths[plugin.id], contentDescription = plugin.name, modifier = Modifier.fillMaxSize(), fallback = { - Icon( + MillenniumIcon( imageVector = ability.abilityIcon(), contentDescription = null, modifier = Modifier.size(18.dp), - tint = ability.accentColor(), - ) - }, - ) + tint = ability.accentColor()) + }) } }, onClick = { onSelected(plugin) }, - selected = selected?.name == plugin.name, - ) + selected = selected?.name == plugin.name) ) } } else { @@ -200,8 +193,7 @@ private fun DefaultAbilityPluginCard( AdaptiveMenuItem( label = noPluginsText, onClick = { }, - enabled = false, - ) + enabled = false) ) } } @@ -216,23 +208,19 @@ private fun DefaultAbilityPluginCard( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { AbilityBadge(ability) Column(modifier = Modifier.weight(1f)) { Text( stringResource( Res.string.settings_plugins_default_ability_title, - ability.displayLabel(), - ), - style = MaterialTheme.typography.titleMedium, - ) + ability.displayLabel()), + style = AppTheme.typography.titleMedium) selected?.let { Text( it.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) } } } @@ -244,10 +232,8 @@ private fun DefaultAbilityPluginCard( logoPath = selection.pluginLogoPaths[selected?.id], pluginName = selected?.name ?: noSelectionText, version = selected?.version, - onClick = onClick, - ) - }, - ) + onClick = onClick) + }) } @Composable @@ -258,8 +244,7 @@ private fun AbilityPluginTile( pluginName: String, version: String?, onClick: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val accent = ability.accentColor() val gradient = ability.aeroGradient() val shape = RoundedCornerShape(20.dp) @@ -275,8 +260,7 @@ private fun AbilityPluginTile( val background = Brush.linearGradient( colors = gradient, start = Offset.Zero, - end = Offset(size.width, size.height), - ) + end = Offset(size.width, size.height)) onDrawBehind { drawRect(background) drawAeroDecorations(seed) @@ -285,71 +269,59 @@ private fun AbilityPluginTile( brush = Brush.verticalGradient( colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.32f)), startY = size.height * 0.3f, - endY = size.height, - ), - ) + endY = size.height)) // Glossy top highlight. drawRect( brush = Brush.verticalGradient( colors = listOf(Color.White.copy(alpha = 0.35f), Color.Transparent), startY = 0f, - endY = size.height * 0.55f, - ), - ) + endY = size.height * 0.55f)) } - }, - ) { + }) { Column( modifier = Modifier .fillMaxWidth() .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { Box( modifier = Modifier .size(52.dp) .clip(CircleShape) .background(Color.White.copy(alpha = 0.35f)) .border(1.dp, Color.White.copy(alpha = 0.6f), CircleShape), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { PluginLogo( plugin = selectedPlugin, logoPath = logoPath, contentDescription = pluginName, modifier = Modifier.fillMaxSize(), fallback = { - Icon( + MillenniumIcon( imageVector = ability.abilityIcon(), contentDescription = null, modifier = Modifier.size(26.dp), - tint = accent, - ) - }, - ) + tint = accent) + }) } - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxArrowDown4, contentDescription = null, modifier = Modifier.size(16.dp), - tint = Color.White.copy(alpha = 0.85f), - ) + tint = Color.White.copy(alpha = 0.85f)) } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = pluginName, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = buildString { append(ability.displayLabel()) @@ -357,11 +329,10 @@ private fun AbilityPluginTile( append(" · v").append(version) } }, - style = MaterialTheme.typography.labelMedium, + style = AppTheme.typography.labelMedium, color = Color.White.copy(alpha = 0.85f), maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } @@ -370,16 +341,15 @@ private fun AbilityPluginTile( @Composable private fun AbilityBadge(ability: PluginAbility) { val accent = ability.accentColor() - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = accent.copy(alpha = 0.12f), - ) { - Icon( + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(accent.copy(alpha = 0.12f))) { + MillenniumIcon( imageVector = ability.abilityIcon(), contentDescription = null, modifier = Modifier.padding(8.dp), - tint = accent, - ) + tint = accent) } } @@ -392,29 +362,24 @@ private fun DrawScope.drawAeroDecorations(seed: Int) { repeat(6) { val center = Offset( x = random.nextFloat() * size.width, - y = random.nextFloat() * size.height, - ) + y = random.nextFloat() * size.height) val radius = size.minDimension * (0.12f + random.nextFloat() * 0.4f) drawCircle( brush = Brush.radialGradient( colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent), center = center, - radius = radius, - ), + radius = radius), radius = radius, - center = center, - ) + center = center) } repeat(3) { val center = Offset( x = random.nextFloat() * size.width, - y = random.nextFloat() * size.height, - ) + y = random.nextFloat() * size.height) val radius = size.minDimension * (0.03f + random.nextFloat() * 0.05f) drawCircle( color = Color.White.copy(alpha = 0.28f), radius = radius, - center = center, - ) + center = center) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt index b2a34f72..9fce3eb4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt @@ -17,7 +17,14 @@ package dev.krtirtho.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,10 +35,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,8 +47,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import okio.Path -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS import dev.krtirtho.spotube.modules.plugin.PluginAbility import dev.krtirtho.spotube.modules.plugin.PluginEntry @@ -72,8 +77,7 @@ internal fun PluginCard( onLogout: (() -> Unit)? = null, onInfo: (() -> Unit)? = null, onSupport: (() -> Unit)? = null, - logoPath: Path? = null, -) { + logoPath: Path? = null) { Row( modifier = Modifier .fillMaxWidth() @@ -82,11 +86,11 @@ internal fun PluginCard( horizontalArrangement = Arrangement.spacedBy(14.dp) ) { // Plugin icon - Surface( + Box( modifier = Modifier .size(44.dp) - .clip(RoundedCornerShape(10.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + .clip(RoundedCornerShape(10.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) ) { PluginLogo( plugin = plugin, @@ -95,15 +99,14 @@ internal fun PluginCard( modifier = Modifier.fillMaxSize(), fallback = { Box(contentAlignment = Alignment.Center) { - Icon( + MillenniumIcon( Iconsax.IconsaxBox, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = AppTheme.colors.primary, modifier = Modifier.size(22.dp) ) } - }, - ) + }) } // Text content @@ -117,7 +120,7 @@ internal fun PluginCard( ) { Text( plugin.name, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -125,25 +128,26 @@ internal fun PluginCard( ) // "Active" chip if (isSelected) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) ) { Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckSquare, contentDescription = null, modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) Text( stringResource(Res.string.plugin_state_active), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary ) } } @@ -153,8 +157,8 @@ internal fun PluginCard( if (plugin.description.isNotBlank()) { Text( plugin.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -169,16 +173,16 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.User, contentDescription = null, modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( plugin.author, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } if (plugin !in BUILT_IN_PLUGINS) @@ -186,16 +190,16 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxTag, contentDescription = null, modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( stringResource(Res.string.plugin_version_label, plugin.version), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } @@ -204,14 +208,15 @@ internal fun PluginCard( if (plugin.abilities.isNotEmpty()) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { plugin.abilities.forEach { ability -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) ) { Text( ability.displayLabel(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, modifier = Modifier.padding( horizontal = 6.dp, vertical = 2.dp @@ -233,42 +238,45 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically ) { if (onInfo != null) { - GhostIconButton(onClick = onInfo) { - Icon( + MillenniumIconButton( + onClick = onInfo, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxInformation, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } if (onSupport != null) { - GhostIconButton(onClick = onSupport) { - Icon( + MillenniumIconButton( + onClick = onSupport, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxHeart, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } if (plugin in BUILT_IN_PLUGINS) { Text( stringResource(Res.string.plugin_state_builtin), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + BorderStroke(1.dp, AppTheme.colors.outlineVariant), shape = RoundedCornerShape(6.dp) ) .padding(horizontal = 6.dp, vertical = 2.dp) ) } else { - GhostIconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxTrash, contentDescription = stringResource(Res.string.plugin_action_remove), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } @@ -279,10 +287,12 @@ internal fun PluginCard( else -> null } authAction?.let { (action, label) -> - OutlineButton(onClick = action) { + MillenniumButton( + onClick = action, + style = MillenniumButtonStyle.Secondary) { Text( text = stringResource(label), - style = MaterialTheme.typography.labelLarge + style = AppTheme.typography.labelLarge ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt index 81049853..84b675ca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -17,30 +17,25 @@ package dev.krtirtho.spotube.modules.plugin.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,24 +52,25 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.modules.plugin.GitHubRepo import dev.krtirtho.spotube.modules.plugin.GitHubRelease import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxBox -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIndeterminateProgress -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PluginInstallDialog( repo: GitHubRepo, releases: List, isLoadingReleases: Boolean, onDismiss: () -> Unit, - onInstall: (GitHubRelease) -> Unit, -) { + onInstall: (GitHubRelease) -> Unit) { var selectedRelease by remember { mutableStateOf(null) } var expanded by remember { mutableStateOf(false) } @@ -84,173 +80,153 @@ fun PluginInstallDialog( } } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + title = repo.fullName) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) ) { - Surface( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - ) { - Box(contentAlignment = Alignment.Center) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(repo.owner.avatarUrl) - .crossfade(true) - .build(), - contentDescription = repo.owner.login, - modifier = Modifier.fillMaxSize() - ) - } + Box(contentAlignment = Alignment.Center) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier.fillMaxSize() + ) } - Column(modifier = Modifier.weight(1f)) { + } + Column(modifier = Modifier.weight(1f)) { + Text( + repo.fullName, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!repo.description.isNullOrBlank()) { Text( - repo.fullName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + repo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, overflow = TextOverflow.Ellipsis ) - if (!repo.description.isNullOrBlank()) { - Text( - repo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + } + } + } + + if (isLoadingReleases) { + Box( + modifier = Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.Center + ) { + MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) + } + } else if (releases.isEmpty()) { + Text( + "No releases found", + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + Text( + "Select Release", + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = { expanded = it }, + items = releases.map { it.tagName }, + onItemClick = { tagName -> + selectedRelease = releases.firstOrNull { it.tagName == tagName } + expanded = false + }, + modifier = Modifier.fillMaxWidth(), + anchor = { + MillenniumButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + selectedRelease?.tagName ?: "Select a release", + style = AppTheme.typography.bodyMedium + ) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + } + ) + + selectedRelease?.let { release -> + if (release.prerelease) { + Text( + "Pre-release", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.error + ) + } + if (!release.body.isNullOrBlank()) { + Text( + "Release Notes", + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + .padding(12.dp) + ) { + Text( + release.body, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } } } } - }, - actions = { - PrimaryButton( + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { selectedRelease?.let { onInstall(it) } }, enabled = selectedRelease != null && !isLoadingReleases ) { Text("Install") } } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - if (isLoadingReleases) { - Box( - modifier = Modifier.fillMaxWidth().height(120.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(32.dp)) - } - } else if (releases.isEmpty()) { - Text( - "No releases found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - Text( - "Select Release", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it } - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - selectedRelease?.tagName ?: "Select a release", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - } - } - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - releases.forEach { release -> - DropdownMenuItem( - text = { - Column { - Text( - release.tagName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (release == selectedRelease) FontWeight.SemiBold else FontWeight.Normal - ) - if (release.prerelease) { - Text( - "Pre-release", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error - ) - } - } - }, - onClick = { - selectedRelease = release - expanded = false - } - ) - } - } - } - - selectedRelease?.let { release -> - if (!release.body.isNullOrBlank()) { - Text( - "Release Notes", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 200.dp) - .verticalScroll(rememberScrollState()) - .padding(12.dp) - ) { - Text( - release.body, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt index 6db4d779..daffad26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt @@ -44,8 +44,7 @@ fun PluginLogo( modifier: Modifier = Modifier, contentDescription: String? = null, contentScale: ContentScale = ContentScale.Crop, - fallback: @Composable () -> Unit, -) { + fallback: @Composable () -> Unit) { val builtInLogo = plugin?.let { builtInPluginLogo(it) } when { @@ -58,8 +57,7 @@ fun PluginLogo( .build(), contentDescription = contentDescription, contentScale = contentScale, - modifier = modifier, - ) + modifier = modifier) } builtInLogo != null -> { @@ -67,8 +65,7 @@ fun PluginLogo( painter = painterResource(builtInLogo), contentDescription = contentDescription, contentScale = contentScale, - modifier = modifier, - ) + modifier = modifier) } else -> fallback() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt index e4f065d0..40be3978 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -17,10 +17,17 @@ package dev.krtirtho.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -28,10 +35,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,10 +46,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import okio.Path -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.modules.plugin.PluginCapability import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -80,81 +82,119 @@ fun PluginPermissionDialog( existingPlugin: PluginEntry? = null, logoPath: Path? = null, onConfirm: (() -> Unit)? = null, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) + title = title) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center - ) { - PluginLogo( - plugin = pluginInfo, - logoPath = logoPath, - contentDescription = pluginInfo.name, - modifier = Modifier.fillMaxSize(), - fallback = { - Icon( - imageVector = Iconsax.IconsaxBoxAdd, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - }, - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - pluginInfo.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + PluginLogo( + plugin = pluginInfo, + logoPath = logoPath, + contentDescription = pluginInfo.name, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = Iconsax.IconsaxBoxAdd, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(24.dp) + ) + }) } + Column(modifier = Modifier.weight(1f)) { + Text( + pluginInfo.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(top = 8.dp) - ) { - Icon( - Iconsax.User, - contentDescription = null, - modifier = Modifier.size(13.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - pluginInfo.author, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - stringResource( - Res.string.plugin_permissions_author_version, - stringResource(Res.string.plugin_version_label, pluginInfo.version) - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon( + Iconsax.User, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + pluginInfo.author, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource( + Res.string.plugin_permissions_author_version, + stringResource(Res.string.plugin_version_label, pluginInfo.version) + ), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + + if (pluginInfo.description.isNotBlank()) { + Text( + pluginInfo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + MillenniumCard(contentPadding = PaddingValues(14.dp)) { + Text( + message, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurface + ) + } + + existingPlugin?.let { + InstalledComparisonCard( + installedPlugin = it, + incomingPlugin = pluginInfo + ) + } + + Text( + stringResource(Res.string.plugin_permissions_requested_title), + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + + if (pluginInfo.capabilities.isEmpty()) { + Text( + stringResource(Res.string.plugin_permissions_none), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + pluginInfo.capabilities.forEach { capability -> + CapabilityRow(capability) } - }, - actions = { - OutlineButton(onClick = onDismiss) { + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text( if (confirmLabel == null) { stringResource(Res.string.settings_action_close) @@ -164,59 +204,10 @@ fun PluginPermissionDialog( ) } if (confirmLabel != null && onConfirm != null) { - PrimaryButton(onClick = onConfirm) { + MillenniumButton(onClick = onConfirm) { Text(confirmLabel) } } - }, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (pluginInfo.description.isNotBlank()) { - Text( - pluginInfo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - Card { - Text( - message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(14.dp) - ) - } - - existingPlugin?.let { - InstalledComparisonCard( - installedPlugin = it, - incomingPlugin = pluginInfo - ) - } - - Text( - stringResource(Res.string.plugin_permissions_requested_title), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - if (pluginInfo.capabilities.isEmpty()) { - Text( - stringResource(Res.string.plugin_permissions_none), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - pluginInfo.capabilities.forEach { capability -> - CapabilityRow(capability) - } - } } } } @@ -224,32 +215,28 @@ fun PluginPermissionDialog( @Composable private fun InstalledComparisonCard( installedPlugin: PluginEntry, - incomingPlugin: PluginEntry, -) { - Card( + incomingPlugin: PluginEntry) { + MillenniumCard( modifier = Modifier.fillMaxWidth(), - ) { + contentPadding = PaddingValues(14.dp)) { Column( - modifier = Modifier.padding(14.dp), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( stringResource(Res.string.plugin_permissions_compare_title), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { VersionStat( label = stringResource(Res.string.plugin_permissions_installed_label), - value = installedPlugin.version, - ) + value = installedPlugin.version) VersionStat( label = stringResource(Res.string.plugin_permissions_supplied_label), - value = incomingPlugin.version, - ) + value = incomingPlugin.version) } Text( stringResource( @@ -257,8 +244,8 @@ private fun InstalledComparisonCard( installedPlugin.apiVersion, incomingPlugin.apiVersion ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } @@ -269,12 +256,12 @@ private fun VersionStat(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) Text( stringResource(Res.string.plugin_version_label, value), - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = FontWeight.Medium ) } @@ -300,13 +287,11 @@ private fun CapabilityRow(capability: PluginCapability) { ) } - Card( + MillenniumCard( modifier = Modifier.fillMaxWidth(), - ) { + contentPadding = PaddingValues(12.dp)) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp) ) { @@ -314,26 +299,26 @@ private fun CapabilityRow(capability: PluginCapability) { modifier = Modifier .size(34.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)), + .background(AppTheme.colors.errorContainer.copy(alpha = 0.6f)), contentAlignment = Alignment.Center ) { - Icon( + MillenniumIcon( imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error + tint = AppTheme.colors.error ) } Column { Text( label, - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = FontWeight.Medium ) Text( description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index a7640ebd..324c464f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -103,8 +103,6 @@ fun SavedTracksScreen( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) - }, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index bf8fafa4..44ecc52e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -71,20 +71,17 @@ sealed interface SavedTracksScreenState { data class Loaded( override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val totalCount: Int = 0, - ) : Data { + override val totalCount: Int = 0) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( tracks = tracks, nextPagination = nextPagination, - totalCount = totalCount, - ) + totalCount = totalCount) } data class LoadingMore( override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val totalCount: Int = 0, - ) : Data + override val totalCount: Int = 0) : Data } data class Error(val message: String) : SavedTracksScreenState @@ -99,8 +96,7 @@ class SavedTracksViewModel( private val libraryRepository: LibraryRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(SavedTracksScreenState.Loading) @@ -125,8 +121,7 @@ class SavedTracksViewModel( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -134,8 +129,7 @@ class SavedTracksViewModel( }.toSet(), blacklistedTrackIds = blacklistedTracks, blacklistedArtistIds = blacklistedArtists, - forceFavorite = true, - ) + forceFavorite = true) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext(forceFavorite = true)) init { @@ -174,8 +168,7 @@ class SavedTracksViewModel( _state.value = SavedTracksScreenState.Data.Loaded( tracks = tracksResult?.items ?: emptyList(), nextPagination = tracksResult?.nextPagination, - totalCount = totalCount, - ) + totalCount = totalCount) }.onFailure { e -> logger.e(e) { "Failed to load saved tracks" } _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") @@ -192,8 +185,7 @@ class SavedTracksViewModel( _state.value = SavedTracksScreenState.Data.Loaded( tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - totalCount = currentState.totalCount, - ) + totalCount = currentState.totalCount) }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") @@ -211,8 +203,7 @@ class SavedTracksViewModel( RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks", - shuffle = true, - ) + shuffle = true) } fun addSavedTracksToQueue() { @@ -224,8 +215,7 @@ class SavedTracksViewModel( type = RemoteCollectionType.SavedTracks, id = SAVED_TRACKS_COLLECTION_ID, title = "Saved Tracks", - startTrack = track, - ) + startTrack = track) } fun refresh() { @@ -290,8 +280,7 @@ class SavedTracksViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -358,7 +347,6 @@ class SavedTracksViewModel( } } - sealed interface SavedState { data class Success(val data: T) : SavedState data object Loading : SavedState @@ -368,8 +356,7 @@ sealed interface SavedState { @Composable fun rememberIsSavedTracks( trackIds: List, - repository: SavedTracksRepository = koinInject(), -): SavedState> { + repository: SavedTracksRepository = koinInject()): SavedState> { val scope = rememberCoroutineScope() var state by remember { mutableStateOf>>(SavedState.Loading) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index ba3a837e..a3900f93 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -17,6 +17,17 @@ package dev.krtirtho.spotube.modules.search + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -46,15 +57,10 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -65,12 +71,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow 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.graphicsLayer import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -91,8 +94,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField -import dev.krtirtho.spotube.core.ui.base.ChipTab import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ArtistCard @@ -171,8 +172,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -236,8 +236,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track), - ) + isBlacklisted = isTrackBlacklisted(track)) } val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList()) @@ -252,8 +251,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track), - ) + isBlacklisted = isTrackBlacklisted(track)) } fun bulkAddToQueue(tracks: List) { @@ -268,13 +266,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } - BaseScaffold( - topBar = { ApplicationMainBar(backButton = false) } - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar(backButton = false) Column( modifier = Modifier - .padding(innerPadding) - .fillMaxSize() + .fillMaxWidth() + .weight(1f) ) { Column( modifier = Modifier.fillMaxWidth() @@ -282,10 +282,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { SearchBar( query = state.query, onQueryChange = viewModel::onQueryChange, - onClear = viewModel::clearQuery, - onSearch = { - focusManager.clearFocus() - }, recentSearches = state.recentSearches, onRecentSearchClick = { search -> viewModel.applyRecentSearch(search) @@ -295,15 +291,13 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { onClearAllRecentSearches = { viewModel.clearAllRecentSearches() focusManager.clearFocus() - }, - ) + }) if (state.supportedSearchTypes.isNotEmpty()) { SearchTabs( types = state.supportedSearchTypes, selectedType = selectedType, - onTabSelected = viewModel::onTabSelected, - ) + onTabSelected = viewModel::onTabSelected) } } @@ -311,16 +305,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { state.isLoadingSearchTypes -> { SearchLoadingIndicator( modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == null -> { SearchMessage( message = "No search types available", modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.ALL -> { @@ -356,8 +348,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.TRACK -> { @@ -388,8 +379,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.PLAYLIST -> { @@ -398,8 +388,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { playlists = state.playlists, onLoadNextPage = viewModel::loadNextPlaylists, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.ALBUM -> { @@ -408,8 +397,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { albums = state.albums, onLoadNextPage = viewModel::loadNextAlbums, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.ARTIST -> { @@ -418,8 +406,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { artists = state.artists, onLoadNextPage = viewModel::loadNextArtists, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.USER -> { @@ -428,8 +415,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { users = state.users, onLoadNextPage = viewModel::loadNextUsers, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } } } @@ -445,35 +431,31 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist.map { it.id } ) } - }, - ) + }) } } -private sealed interface SearchDropdownEntry { - val onClick: () -> Unit - - data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry - data class RecentSearch( - val query: String, - override val onClick: () -> Unit, - val onRemove: () -> Unit, - ) : SearchDropdownEntry -} - @Composable private fun SearchBar( query: String, onQueryChange: (String) -> Unit, - onClear: () -> Unit, - onSearch: () -> Unit, recentSearches: List, onRecentSearchClick: (String) -> Unit, onRecentSearchRemove: (String) -> Unit, - onClearAllRecentSearches: () -> Unit, -) { + onClearAllRecentSearches: () -> Unit) { val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty() - val dropdownItems = remember(showRecentSearches, recentSearches) { + val searchState = rememberTextFieldState(query) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() }.collect { onQueryChange(it) } + } + LaunchedEffect(query) { + if (searchState.text.toString() != query) { + searchState.edit { replace(0, length, query) } + } + } + + val suggestions = remember(showRecentSearches, recentSearches) { buildList { if (showRecentSearches) { add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches)) @@ -482,8 +464,7 @@ private fun SearchBar( SearchDropdownEntry.RecentSearch( query = search, onClick = { onRecentSearchClick(search) }, - onRemove = { onRecentSearchRemove(search) }, - ) + onRemove = { onRecentSearchRemove(search) }) ) } } @@ -494,128 +475,78 @@ private fun SearchBar( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - ) { - AutocompleteTextField( - value = query, - onValueChange = onQueryChange, - items = dropdownItems, - onItemSelected = { entry -> entry.onClick() }, - placeholder = { - Text( - "Search songs, artists, albums...", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = { - if (query.isNotBlank()) { - IconButton( - onClick = onClear, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = FeatherIcons.X, - contentDescription = "Clear", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), - itemContent = { entry, isSelected -> + .padding(bottom = 8.dp)) { + MillenniumAutocomplete( + state = searchState, + label = "Search", + suggestions = suggestions, + onSuggestionSelected = { entry -> entry.onClick() }, + suggestionLabel = { entry -> when (entry) { - is SearchDropdownEntry.ClearAll -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else Color.Transparent - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp) - ) - Text( - "Clear all history", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - is SearchDropdownEntry.RecentSearch -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else Color.Transparent - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.InconsaxClock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - entry.query, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = entry.onRemove, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = FeatherIcons.X, - contentDescription = "Remove", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } - } - } + is SearchDropdownEntry.ClearAll -> "Clear all history" + is SearchDropdownEntry.RecentSearch -> entry.query } }, - ) + placeholder = "Search songs, artists, albums...", + modifier = Modifier.fillMaxWidth(), + suggestionContent = { entry, _ -> SearchSuggestionRow(entry) }) + } +} + +private sealed interface SearchDropdownEntry { + val onClick: () -> Unit + + data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry + data class RecentSearch( + val query: String, + override val onClick: () -> Unit, + val onRemove: () -> Unit) : SearchDropdownEntry +} + +@Composable +private fun SearchSuggestionRow(entry: SearchDropdownEntry) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + when (entry) { + is SearchDropdownEntry.ClearAll -> { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + tint = AppTheme.colors.error, + modifier = Modifier.size(16.dp)) + Text( + "Clear all history", + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium) + } + + is SearchDropdownEntry.RecentSearch -> { + MillenniumIcon( + imageVector = Iconsax.InconsaxClock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant) + Text( + entry.query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = AppTheme.typography.bodyMedium, + modifier = Modifier.weight(1f)) + MillenniumIconButton( + onClick = entry.onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.X, + contentDescription = "Remove", + modifier = Modifier.size(14.dp), + tint = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f)) + } + } + } } } @@ -623,8 +554,7 @@ private fun SearchBar( private fun SearchTabs( types: List, selectedType: MetadataSupportedSearchType?, - onTabSelected: (MetadataSupportedSearchType) -> Unit, -) { + onTabSelected: (MetadataSupportedSearchType) -> Unit) { val listState = rememberLazyListState() LazyRow( state = listState, @@ -634,10 +564,13 @@ private fun SearchTabs( ) { items(types) { type -> val isSelected = selectedType == type - ChipTab( - selected = isSelected, - onClick = { onTabSelected(type) } - ) { + MillenniumButton( + onClick = { onTabSelected(type) }, + style = if (isSelected) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + }) { Text(type.tabTitle()) } } @@ -667,8 +600,7 @@ private fun SearchAllTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -692,8 +624,7 @@ private fun SearchAllTab( ErrorDisplay( errorMessage = hasAnyError, onRetry = { }, - modifier = modifier, - ) + modifier = modifier) return } @@ -732,10 +663,9 @@ private fun SearchAllTab( if (showTracks) { Text( text = "Tracks", - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp), - ) + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp)) } if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) { if (tracks.isLoading && tracks.items.isEmpty()) { @@ -754,8 +684,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.PLAYLIST in supportedSearchTypes && playlists.items.isNotEmpty()) { SearchHorizontalSection( title = "Playlists", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }) { playlists.items.forEach { playlist -> item(key = playlist.id) { PlaylistCard(playlist = playlist) } } @@ -765,8 +694,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ALBUM in supportedSearchTypes && albums.items.isNotEmpty()) { SearchHorizontalSection( title = "Albums", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }) { albums.items.forEach { album -> item(key = album.id) { AlbumCard(album = album) } } @@ -776,8 +704,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ARTIST in supportedSearchTypes && artists.items.isNotEmpty()) { SearchHorizontalSection( title = "Artists", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }) { artists.items.forEach { artist -> item(key = artist.id) { ArtistCard(artist = artist) } } @@ -787,8 +714,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.USER in supportedSearchTypes && users.items.isNotEmpty()) { SearchHorizontalSection( title = "Users", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }) { users.items.forEach { user -> item(key = user.id) { UserCard(user = user) } } @@ -817,8 +743,7 @@ private fun SearchTracksTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { if (query.isBlank()) { SearchMessage(message = "Start typing to search tracks", modifier = modifier) return @@ -850,8 +775,7 @@ private fun SearchTracksTab( onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, simplified = true, - modifier = modifier, - ) + modifier = modifier) } @Composable @@ -859,8 +783,7 @@ private fun SearchAlbumsTab( query: String, albums: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = albums.items, @@ -870,8 +793,7 @@ private fun SearchAlbumsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No albums found", loadingMessage = "Loading albums...", - modifier = modifier, - ) { album -> + modifier = modifier) { album -> AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) } } @@ -881,8 +803,7 @@ private fun SearchArtistsTab( query: String, artists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = artists.items, @@ -892,8 +813,7 @@ private fun SearchArtistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No artists found", loadingMessage = "Loading artists...", - modifier = modifier, - ) { artist -> + modifier = modifier) { artist -> ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) } } @@ -903,8 +823,7 @@ private fun SearchPlaylistsTab( query: String, playlists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = playlists.items, @@ -914,8 +833,7 @@ private fun SearchPlaylistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No playlists found", loadingMessage = "Loading playlists...", - modifier = modifier, - ) { playlist -> + modifier = modifier) { playlist -> PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) } } @@ -925,8 +843,7 @@ private fun SearchUsersTab( query: String, users: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = users.items, @@ -936,8 +853,7 @@ private fun SearchUsersTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No users found", loadingMessage = "Loading users...", - modifier = modifier, - ) { user -> + modifier = modifier) { user -> UserCard(user = user, modifier = Modifier.fillMaxWidth()) } } @@ -954,8 +870,7 @@ private fun SearchGridTab( emptyMessage: String, loadingMessage: String, modifier: Modifier = Modifier, - itemContent: @Composable (T) -> Unit, -) { + itemContent: @Composable (T) -> Unit) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -982,16 +897,14 @@ private fun SearchGridTab( isLoading && items.isEmpty() -> { SearchLoadingIndicator( message = loadingMessage, - modifier = modifier, - ) + modifier = modifier) } error != null && items.isEmpty() -> { ErrorDisplay( errorMessage = error, onRetry = onLoadNextPage, - modifier = modifier, - ) + modifier = modifier) } !isLoading && items.isEmpty() -> { @@ -1005,8 +918,7 @@ private fun SearchGridTab( modifier = modifier.padding(horizontal = 12.dp), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { items(items) { item -> itemContent(item) } @@ -1017,8 +929,7 @@ private fun SearchGridTab( PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -1031,38 +942,32 @@ private fun SearchGridTab( private fun SearchHorizontalSection( title: String, onSeeAll: () -> Unit, - content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, -) { + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold) Text( text = "See all", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable(onClick = onSeeAll), - ) + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.primary, + modifier = Modifier.clickable(onClick = onSeeAll)) } LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - content = content, - ) + content = content) } } @@ -1070,46 +975,39 @@ private fun SearchHorizontalSection( private fun SearchTrackRow( index: Int, track: MetadataTrack, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Row( modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = (index + 1).toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.End) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = track.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - ) + style = AppTheme.typography.bodyLarge, + maxLines = 1) Text( text = track.artists.joinToString { it.name }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1) } if (track.album != null) Text( text = track.album!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) } } @@ -1120,33 +1018,27 @@ private val WarmSearchMessages = listOf( "Dusting off the vinyl...", "Consulting the music gods...", "Tuning the antennas...", - "Flipping through the records...", -) + "Flipping through the records...") @Composable private fun SearchLoadingIndicator( message: String? = null, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val infiniteTransition = rememberInfiniteTransition(label = "search-loading") val rotation by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "icon-rotation", - ) + repeatMode = RepeatMode.Restart), + label = "icon-rotation") val bounce by infiniteTransition.animateFloat( initialValue = -8f, targetValue = 8f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 800, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "icon-bounce", - ) + repeatMode = RepeatMode.Reverse), + label = "icon-bounce") val messageIndex by infiniteTransition.animateFloat( initialValue = 0f, targetValue = WarmSearchMessages.size.toFloat(), @@ -1155,52 +1047,44 @@ private fun SearchLoadingIndicator( durationMillis = WarmSearchMessages.size * 2000, easing = LinearEasing ), - repeatMode = RepeatMode.Restart, - ), - label = "message-cycle", - ) + repeatMode = RepeatMode.Restart), + label = "message-cycle") val displayMessage = message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size] Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { Box( modifier = Modifier.size(64.dp), - contentAlignment = Alignment.Center, - ) { - Icon( + contentAlignment = Alignment.Center) { + MillenniumIcon( imageVector = Iconsax.IconsaxSearchBroken, contentDescription = "Searching", - tint = MaterialTheme.colorScheme.primary, + tint = AppTheme.colors.primary, modifier = Modifier .size(48.dp) .graphicsLayer { rotationZ = rotation translationY = bounce - }, - ) + }) } - LinearProgressIndicator( + MillenniumIndeterminateProgress( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp), - ) + .padding(horizontal = 32.dp, vertical = 16.dp)) AnimatedContent( targetState = displayMessage, transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }, - label = "message-transition", - ) { msg -> + label = "message-transition") { msg -> Text( text = msg, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + textAlign = TextAlign.Center) } } } @@ -1209,35 +1093,30 @@ private fun SearchLoadingIndicator( private fun SearchMessage( message: String, modifier: Modifier = Modifier, - isError: Boolean = false, -) { + isError: Boolean = false) { Box( modifier = modifier, - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, textAlign = TextAlign.Center, - modifier = Modifier.padding(16.dp), - ) + modifier = Modifier.padding(16.dp)) } } @Composable private fun SearchMessageInline( message: String, - isError: Boolean = false, -) { + isError: Boolean = false) { Text( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt index 5f233b9a..5134106a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt @@ -47,8 +47,7 @@ data class SearchPagedState( val nextPagination: PaginationStrategy? = null, val hasNextPage: Boolean = true, val isLoading: Boolean = false, - val error: String? = null, -) + val error: String? = null) data class SearchScreenState( val query: String = "", @@ -60,15 +59,13 @@ data class SearchScreenState( val albums: SearchPagedState = SearchPagedState(), val artists: SearchPagedState = SearchPagedState(), val playlists: SearchPagedState = SearchPagedState(), - val users: SearchPagedState = SearchPagedState(), -) + val users: SearchPagedState = SearchPagedState()) @OptIn(ExperimentalCoroutinesApi::class) class SearchScreenViewModel( private val repository: SearchRepository, private val savedTracksRepository: SavedTracksRepository, - private val database: Database, -) : ViewModel() { + private val database: Database) : ViewModel() { companion object { private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches") private val SEARCH_TYPES_ORDER = listOf( @@ -77,8 +74,7 @@ class SearchScreenViewModel( MetadataSupportedSearchType.PLAYLIST, MetadataSupportedSearchType.ALBUM, MetadataSupportedSearchType.ARTIST, - MetadataSupportedSearchType.USER, - ) + MetadataSupportedSearchType.USER) private const val QUERY_DEBOUNCE_MS = 350L private const val RECENT_SEARCHES_LIMIT = 12 private const val TRACK_PAGE_SIZE = 20 @@ -130,8 +126,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) } fun clearAllRecentSearches() { @@ -157,8 +152,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) if (_state.value.query.isNotBlank()) { viewModelScope.launch { refreshCurrentSelection(reset = true) @@ -232,8 +226,7 @@ class SearchScreenViewModel( _state.value = _state.value.copy( supportedSearchTypes = ordered, selectedSearchType = selectedType, - isLoadingSearchTypes = false, - ) + isLoadingSearchTypes = false) if (_state.value.query.isNotBlank()) { refreshCurrentSelection(reset = true) @@ -243,8 +236,7 @@ class SearchScreenViewModel( isLoadingSearchTypes = false, supportedSearchTypes = emptyList(), selectedSearchType = null, - tracks = SearchPagedState(error = it.message ?: "Failed to load search capabilities"), - ) + tracks = SearchPagedState(error = it.message ?: "Failed to load search capabilities")) } } } @@ -257,8 +249,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) return } @@ -284,8 +275,7 @@ class SearchScreenViewModel( albums = SearchPagedState(isLoading = true), artists = SearchPagedState(isLoading = true), playlists = SearchPagedState(isLoading = true), - users = SearchPagedState(isLoading = true), - ) + users = SearchPagedState(isLoading = true)) if (MetadataSupportedSearchType.ALL in supported) { runCatching { @@ -305,41 +295,34 @@ class SearchScreenViewModel( items = tracks.take(TRACK_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), playlists = SearchPagedState( items = playlists.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), albums = SearchPagedState( items = albums.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), artists = SearchPagedState( items = artists.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), users = SearchPagedState( items = users.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), - ) + isLoading = false)) }.onFailure { _state.value = _state.value.copy( tracks = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), playlists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), albums = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), artists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - users = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - ) + users = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search")) } return@coroutineScope } @@ -356,8 +339,7 @@ class SearchScreenViewModel( playlists = _state.value.playlists.copy(items = _state.value.playlists.items.take(ALL_ROW_PAGE_SIZE)), albums = _state.value.albums.copy(items = _state.value.albums.items.take(ALL_ROW_PAGE_SIZE)), artists = _state.value.artists.copy(items = _state.value.artists.items.take(ALL_ROW_PAGE_SIZE)), - users = _state.value.users.copy(items = _state.value.users.items.take(ALL_ROW_PAGE_SIZE)), - ) + users = _state.value.users.copy(items = _state.value.users.items.take(ALL_ROW_PAGE_SIZE))) } private suspend fun loadTracks(reset: Boolean) { @@ -390,16 +372,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( tracks = _state.value.tracks.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search tracks", - ) + error = throwable.message ?: "Failed to search tracks") ) } } @@ -432,16 +412,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( albums = _state.value.albums.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search albums", - ) + error = throwable.message ?: "Failed to search albums") ) } } @@ -474,16 +452,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( artists = _state.value.artists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search artists", - ) + error = throwable.message ?: "Failed to search artists") ) } } @@ -516,16 +492,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( playlists = _state.value.playlists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search playlists", - ) + error = throwable.message ?: "Failed to search playlists") ) } } @@ -558,16 +532,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( users = _state.value.users.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search users", - ) + error = throwable.message ?: "Failed to search users") ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt index da022080..411935f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt @@ -1,13 +1,15 @@ package dev.krtirtho.spotube.modules.settings import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Text +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -15,8 +17,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard @@ -57,31 +57,24 @@ import spotube.composeapp.generated.resources.settings_section_jam @Composable fun JamSettingsScreen( - viewModel: JamSettingsViewModel = koinViewModel(), -) { - BaseScaffold( - topBar = { - ApplicationMainBar( - title = { Text(stringResource(Res.string.settings_section_jam)) }, - ) - }, - ) { innerPadding -> + viewModel: JamSettingsViewModel = koinViewModel()) { + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { Text(stringResource(Res.string.settings_section_jam)) }) JamSettingsContent( viewModel = viewModel, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } /** - * The Group Jam settings UI without its own [BaseScaffold] / app bar, so it can be embedded + * The Group Jam settings UI without its own app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen]. */ @Composable internal fun JamSettingsContent( viewModel: JamSettingsViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val broker = uiState.settings.jamBroker val shellBottomInset = LocalAppShellBottomInset.current @@ -91,14 +84,12 @@ internal fun JamSettingsContent( Box( modifier = modifier - .fillMaxSize(), - ) { + .fillMaxSize()) { LazyColumn( modifier = Modifier .widthIn(max = 1280.dp) .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { + contentPadding = contentPadding) { settingsSectionHeader(Res.string.settings_jam_section_connection) settingsSectionCard( items = listOf( @@ -116,8 +107,7 @@ internal fun JamSettingsContent( ) ) } - }, - ) + }) }, { val errorWholeNumber = @@ -146,8 +136,7 @@ internal fun JamSettingsContent( Iconsax.CustomServer, stringResource(Res.string.settings_jam_broker_port) ) - }, - ) + }) }, { SwitchSettingCard( @@ -162,11 +151,8 @@ internal fun JamSettingsContent( Iconsax.IconsaxWifiSquare, stringResource(Res.string.settings_jam_broker_tls) ) - }, - ) - }, - ), - ) + }) + })) settingsSectionHeader(Res.string.settings_jam_section_credentials) settingsSectionCard( items = listOf( @@ -185,8 +171,7 @@ internal fun JamSettingsContent( Iconsax.User, stringResource(Res.string.settings_jam_broker_username) ) - }, - ) + }) }, { TextInputSettingCard( @@ -203,11 +188,8 @@ internal fun JamSettingsContent( Iconsax.IconsaxEye, stringResource(Res.string.settings_jam_broker_password) ) - }, - ) - }, - ), - ) + }) + })) settingsSectionHeader(Res.string.settings_jam_section_identity) settingsSectionCard( items = listOf( @@ -227,11 +209,8 @@ internal fun JamSettingsContent( Iconsax.IconsaxTag, stringResource(Res.string.settings_jam_broker_client_id) ) - }, - ) - }, - ), - ) + }) + })) settingsSectionHeader(Res.string.settings_jam_section_diagnostics) settingsSectionCard( items = listOf( @@ -239,8 +218,7 @@ internal fun JamSettingsContent( val canTest = broker.host.isNotBlank() && !uiState.isTesting val resultMessage = uiState.testResult?.fold( onSuccess = { it }, - onFailure = { "Failed: ${it.message ?: "Connection failed"}" }, - ) + onFailure = { "Failed: ${it.message ?: "Connection failed"}" }) SettingCardItem( enabled = canTest, @@ -253,35 +231,28 @@ internal fun JamSettingsContent( icon = { SettingsItemIcon( Iconsax.IconsaxInformation, - stringResource(Res.string.settings_jam_broker_test), - ) + stringResource(Res.string.settings_jam_broker_test)) }, trailingContent = { - PrimaryButton( + MillenniumButton( onClick = viewModel::testConnection, - enabled = canTest, - ) { + enabled = canTest) { Text( if (uiState.isTesting) { stringResource(Res.string.settings_jam_broker_testing) } else { stringResource(Res.string.settings_jam_broker_test) - }, - ) + }) } }, - onClick = viewModel::testConnection, - ) - }, - ), - ) + onClick = viewModel::testConnection) + })) item { Text( text = stringResource(Res.string.settings_jam_broker_placeholder_note), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - ) + .padding(horizontal = 16.dp, vertical = 4.dp)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt index 010fc6e2..ad66d86a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt @@ -12,13 +12,11 @@ import kotlinx.coroutines.launch data class JamSettingsUiState( val settings: UserSettings = UserSettings(), val isTesting: Boolean = false, - val testResult: Result? = null, -) + val testResult: Result? = null) class JamSettingsViewModel( private val settingsRepository: SettingsRepository, - private val jamRoomClient: JamRoomClient, -) : ViewModel() { + private val jamRoomClient: JamRoomClient) : ViewModel() { private val _uiState = MutableStateFlow(JamSettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index d51fb20e..b3dc1c60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -19,8 +19,8 @@ package dev.krtirtho.spotube.modules.settings import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.spotube.core.ui.base.SurfaceStyle import kotlinx.serialization.Serializable +import org.spotube.millennium.theme.MillenniumPalette @Serializable enum class Theme { @@ -35,8 +35,7 @@ data class UserSettings( // Appearance val theme: Theme = Theme.SYSTEM, - val accentColor: AccentColors = AccentColors.GREEN_GOBLIN, - val surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, + val palette: MillenniumPalette = MillenniumPalette.Blue, // Playback val streamingMusicFormat: AudioFormat = AudioFormat( @@ -46,8 +45,7 @@ data class UserSettings( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ), val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), val enableMusicCaching: Boolean = true, @@ -78,8 +76,7 @@ data class UserSettings( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ), val downloadMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), @@ -90,8 +87,7 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, val ignoredAppUpdateVersion: String? = null, - val ignoredPluginUpdateVersions: Map = emptyMap(), -) + val ignoredPluginUpdateVersions: Map = emptyMap()) /** * Configuration for the MQTT broker used by Group Jam. The host is a placeholder @@ -107,5 +103,4 @@ data class JamBroker( val password: String? = null, val clientIdPrefix: String = "spotube", val keepAliveSeconds: Int = 30, - val connectionTimeoutSeconds: Int = 10, -) + val connectionTimeoutSeconds: Int = 10) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt index 1d6b0d6f..8313d990 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt @@ -37,6 +37,16 @@ interface UserSettingsSource { class SettingsRepository(private val database: Database) : UserSettingsSource { companion object { private val SETTINGS_KEY = stringPreferencesKey("user_settings") + + /** + * Lenient so settings written by older builds (which stored `accentColor` / + * `surfaceTheme` instead of the Millennium `palette`) still decode, and so a + * missing field falls back to its default. + */ + private val settingsJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } } val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -44,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { - Json.decodeFromString(json as String) + settingsJson.decodeFromString(json as String) } else { UserSettings() // Default value } @@ -56,13 +66,13 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { suspend fun updateSettings(newSettings: UserSettings) { database.settingsDataStore.edit { prefs -> - prefs[SETTINGS_KEY] = Json.encodeToString(newSettings) + prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings) } } suspend fun getCurrentSettings(): UserSettings { val preferences = database.settingsDataStore.data.first() - val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings() - return Json.decodeFromString(settingsJson) + val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() + return settingsJson.decodeFromString(storedJson) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 694c9d18..add9858d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -19,13 +19,13 @@ package dev.krtirtho.spotube.modules.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -38,7 +38,6 @@ import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.BaseScaffold import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* import dev.krtirtho.spotube.modules.settings.sections.appearanceSection @@ -72,15 +71,13 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { if (maxWidth >= SettingsDialogMinWidth) { SettingsDialog( settingsViewModel = settingsViewModel, - onDismiss = { navigatorCommands.pop(Routes.Settings) }, - ) + onDismiss = { navigatorCommands.pop(Routes.Settings) }) } else { CompactSettingsScreen(settingsViewModel = settingsViewModel) } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { val navigatorCommands = koinInject() @@ -91,24 +88,20 @@ private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } - BaseScaffold( - topBar = { - ApplicationMainBar( - title = { - Text(stringResource(Res.string.settings_screen_title)) - }, - backButton = false - ) - } - ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Text(stringResource(Res.string.settings_screen_title)) + }, + backButton = false + ) CompactSettingsContent( settings = settingsState, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, contentPadding = contentPadding, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } @@ -119,8 +112,7 @@ private fun CompactSettingsContent( navigatorCommands: NavigationCommands, requestLocalNetworkPermission: () -> Unit, contentPadding: PaddingValues, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val platformType = remember { getPlatform().type } val isDesktopPlatform = platformType == PlatformType.Windows || platformType == PlatformType.Linux || @@ -131,48 +123,38 @@ private fun CompactSettingsContent( modifier = Modifier .widthIn(max = 1280.dp) .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { + contentPadding = contentPadding) { pluginsSection( - navigatorCommands = navigatorCommands, - ) + navigatorCommands = navigatorCommands) if (settings != null) { languageRegionSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) appearanceSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) playbackSection( settings = settings, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, - requestLocalNetworkPermission = requestLocalNetworkPermission, - ) + requestLocalNetworkPermission = requestLocalNetworkPermission) jamSection( settings = settings, - navigationCommands = navigatorCommands, - ) + navigationCommands = navigatorCommands) cacheSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) downloadsSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) if (isDesktopPlatform) { desktopSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) } updatesSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt index 01e560cd..0104944a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt @@ -17,9 +17,15 @@ package dev.krtirtho.spotube.modules.settings -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,12 +40,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -49,8 +53,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -60,8 +62,6 @@ import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.component.SidebarItem import dev.krtirtho.spotube.modules.blacklist.BlacklistContent import dev.krtirtho.spotube.modules.plugin.PluginScreenContent @@ -113,8 +113,7 @@ private val SettingsSidebarWidth = 236.dp */ internal enum class SettingsTab( val titleRes: StringResource, - val icon: ImageVector, -) { + val icon: ImageVector) { Plugins(Res.string.settings_section_plugins, Iconsax.IconsaxBoxAdd), LanguageRegion(Res.string.settings_section_language_region, Iconsax.IconsaxLanguageSquare), Appearance(Res.string.settings_section_appearance, Iconsax.IconsaxColorSwatch), @@ -134,19 +133,16 @@ internal enum class SettingsTab( @Composable internal fun SettingsDialog( settingsViewModel: SettingsViewModel, - onDismiss: () -> Unit, -) { + onDismiss: () -> Unit) { Dialog( onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { + properties = DialogProperties(usePlatformDefaultWidth = false)) { // The dialog is its own surface; the player/bottom bars behind it should not // reserve any inset inside the dialog content. CompositionLocalProvider(LocalAppShellBottomInset provides 0.dp) { SettingsPane( settingsViewModel = settingsViewModel, - onClose = onDismiss, - ) + onClose = onDismiss) } } } @@ -154,13 +150,11 @@ internal fun SettingsDialog( @Composable private fun SettingsPane( settingsViewModel: SettingsViewModel, - onClose: () -> Unit, -) { + onClose: () -> Unit) { val navigatorCommands: NavigationCommands = koinInject() val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val contentPadding = remember { PaddingValues(top = 16.dp, bottom = 24.dp) } - val dialogTheme = LocalBaseUITheme.current.dialog val platformType = remember { getPlatform().type } val isDesktopPlatform = platformType == PlatformType.Windows || @@ -177,73 +171,57 @@ private fun SettingsPane( modifier = Modifier .fillMaxSize() .padding(32.dp), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Row( modifier = Modifier .widthIn(max = 1200.dp) .heightIn(max = 860.dp) .fillMaxWidth() .fillMaxHeight() - .shadow( - elevation = dialogTheme.shadow.elevation, - shape = dialogTheme.shape, - ambientColor = dialogTheme.shadow.ambientColor, - spotColor = dialogTheme.shadow.spotColor, - ) - .clip(dialogTheme.shape) - .background(dialogTheme.background, dialogTheme.shape) - .border( - BorderStroke(dialogTheme.border.width, dialogTheme.border.color), - dialogTheme.shape, - ), - ) { + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.large, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 16.dp)) { SettingsSidebar( tabs = tabs, selectedTab = selectedTab, - onSelect = { selectedIndex = tabs.indexOf(it) }, - ) - VerticalDivider( + onSelect = { selectedIndex = tabs.indexOf(it) }) + MillenniumSeparator( modifier = Modifier.fillMaxHeight(), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - thickness = 1.dp, - ) + vertical = true) Column( modifier = Modifier .weight(1f) - .fillMaxHeight(), - ) { + .fillMaxHeight()) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Box(modifier = Modifier.weight(1f)) { SettingsPageTitle(selectedTab.titleRes) } - GhostIconButton( + MillenniumIconButton( onClick = onClose, modifier = Modifier.padding(end = 8.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Close", - ) + contentDescription = "Close") } } - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + MillenniumSeparator() Box( modifier = Modifier .weight(1f) - .fillMaxWidth(), - ) { + .fillMaxWidth()) { SettingsTabContent( tab = selectedTab, settings = settingsState, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, - contentPadding = contentPadding, - ) + contentPadding = contentPadding) } } } @@ -254,19 +232,17 @@ private fun SettingsPane( private fun SettingsSidebar( tabs: List, selectedTab: SettingsTab, - onSelect: (SettingsTab) -> Unit, -) { + onSelect: (SettingsTab) -> Unit) { LazyColumn( modifier = Modifier .width(SettingsSidebarWidth) .fillMaxHeight() .padding(horizontal = 8.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { item { Text( stringResource(Res.string.settings_screen_title), - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 16.dp) ) } @@ -275,8 +251,7 @@ private fun SettingsSidebar( label = stringResource(tab.titleRes), icon = tab.icon, selected = tab == selectedTab, - onClick = { onSelect(tab) }, - ) + onClick = { onSelect(tab) }) } } } @@ -288,8 +263,7 @@ private fun SettingsTabContent( settingsViewModel: SettingsViewModel, navigatorCommands: NavigationCommands, requestLocalNetworkPermission: () -> Unit, - contentPadding: PaddingValues, -) { + contentPadding: PaddingValues) { when (tab) { SettingsTab.Plugins -> { PluginScreenContent(viewModel = koinViewModel()) @@ -307,9 +281,8 @@ private fun SettingsTabContent( if (settings == null) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() + contentAlignment = Alignment.Center) { + MillenniumIndeterminateProgress() } } else { Box(modifier = Modifier.fillMaxSize()) { @@ -317,8 +290,7 @@ private fun SettingsTabContent( modifier = Modifier .widthIn(max = 1280.dp) .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { + contentPadding = contentPadding) { when (tab) { SettingsTab.LanguageRegion -> languageRegionSection(settings, settingsViewModel, includeHeader = false) @@ -333,8 +305,7 @@ private fun SettingsTabContent( navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, includeBlacklist = false, - includeHeader = false, - ) + includeHeader = false) SettingsTab.Caching -> cacheSection(settings, settingsViewModel, includeHeader = false) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt index 88b4878d..42af007c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt @@ -28,7 +28,6 @@ interface SettingsProvider { val settingsState: StateFlow } - class SettingsViewModel( private val repository: SettingsRepository ) : ViewModel(), SettingsProvider { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt deleted file mode 100644 index 489a8b9f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.modules.settings - -import androidx.compose.ui.graphics.Color -import dev.krtirtho.spotube.core.ui.base.SurfaceStyle - -/** - * Accent palettes that are known to pair well with each surface material. - * - * The appearance picker only offers these combinations, so a user can never end up with a - * clashing surface + accent pair — instead of the old behaviour where any accent could be - * applied to any surface. - */ -val SurfaceStyle.recommendedAccents: List - get() = when (this) { - SurfaceStyle.Watery -> listOf( - AccentColors.OCEANIC_CYAN, - AccentColors.MIDNIGHT_BLUE, - AccentColors.GREEN_GOBLIN, - AccentColors.SUNSET_ORANGE, - ) - - SurfaceStyle.Icy -> listOf( - AccentColors.MIDNIGHT_BLUE, - AccentColors.OCEANIC_CYAN, - AccentColors.ELECTRIC_VIOLET, - AccentColors.ROSE_GARDEN, - ) - - SurfaceStyle.Chrome -> listOf( - AccentColors.METALLIC_SLATE, - AccentColors.MIDNIGHT_BLUE, - AccentColors.SUNSET_ORANGE, - AccentColors.GREEN_GOBLIN, - ) - - SurfaceStyle.Acrylic -> listOf( - AccentColors.METALLIC_SLATE, - AccentColors.ELECTRIC_VIOLET, - AccentColors.OCEANIC_CYAN, - AccentColors.ROSE_GARDEN, - ) - - SurfaceStyle.Glass -> listOf( - AccentColors.GREEN_GOBLIN, - AccentColors.ELECTRIC_VIOLET, - AccentColors.OCEANIC_CYAN, - AccentColors.ROSE_GARDEN, - ) - } - -/** The concrete accent colour for the current light/dark mode. */ -fun AccentColors.colorFor(isLight: Boolean): Color = - if (isLight) toLightColor() else toDarkColor() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index 35736f3c..98cfc5bc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -17,40 +17,41 @@ package dev.krtirtho.spotube.modules.settings.components -import androidx.compose.foundation.clickable + +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumRadioGroup +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.Radio -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.base.Toggle import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import kotlinx.coroutines.flow.drop import spotube.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -60,17 +61,16 @@ internal fun SwitchSettingCard( subtitle: String? = null, checked: Boolean, onCheckedChange: (Boolean) -> Unit, - icon: (@Composable () -> Unit)? = null, -) { + icon: (@Composable () -> Unit)? = null) { SettingCardItem( title = title, subtitle = subtitle, icon = icon, trailingContent = { - Toggle( + MillenniumSwitch( checked = checked, onCheckedChange = onCheckedChange, - ) + label = title) }, onClick = { onCheckedChange(!checked) @@ -89,8 +89,7 @@ internal fun SelectionSettingCard( dialogTitle: String = title, icon: (@Composable () -> Unit)? = null, inlineSelectorMinWidth: Dp = 700.dp, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { var isDialogOpen by remember { mutableStateOf(false) } BoxWithConstraints { @@ -106,21 +105,24 @@ internal fun SelectionSettingCard( AdaptiveMenuItem( label = optionLabel(option), onClick = { onOptionSelected(option) }, - selected = option == selectedOption, - ) + selected = option == selectedOption) }, trigger = { onClick -> - OutlineButton(onClick = onClick) { - Text(optionLabel(selectedOption)) - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(optionLabel(selectedOption)) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp)) + } } }, - filter = filter, - ) + filter = filter) }, onClick = { if (isWideLayout) { @@ -130,45 +132,24 @@ internal fun SelectionSettingCard( ) if (!isWideLayout && isDialogOpen) { - ThemedDialog( + val labels = options.map { optionLabel(it) } + MillenniumDialog( + visible = true, onDismissRequest = { isDialogOpen = false }, - title = { - Text(dialogTitle, style = MaterialTheme.typography.titleLarge) - }, - actions = { - OutlineButton(onClick = { isDialogOpen = false }) { - Text(stringResource(Res.string.settings_action_close)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - options.forEach { option -> - val isSelected = option == selectedOption - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable { - onOptionSelected(option) - isDialogOpen = false - } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Radio( - selected = isSelected, - onClick = { - onOptionSelected(option) - isDialogOpen = false - } - ) - Text( - text = optionLabel(option), - style = MaterialTheme.typography.bodyMedium - ) - } - } + title = dialogTitle) { + MillenniumRadioGroup( + value = selectedOption, + onValueChange = { option -> + onOptionSelected(option) + isDialogOpen = false + }, + options = options, + label = dialogTitle, + optionLabel = { option -> labels[options.indexOf(option)] }) + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.settings_action_close)) } } } @@ -189,13 +170,23 @@ internal fun TextInputSettingCard( icon: (@Composable () -> Unit)? = null, inlineTextFieldMinWidth: Dp = 700.dp, inlineTextFieldWidth: Dp = 220.dp, - enabled: Boolean = true, -) { + enabled: Boolean = true) { var isDialogOpen by remember { mutableStateOf(false) } BoxWithConstraints { val isWideLayout = maxWidth >= inlineTextFieldMinWidth - var inlineDraft by remember(value, isWideLayout) { mutableStateOf(value) } + val inlineState = rememberTextFieldState(value) + + LaunchedEffect(inlineState, isWideLayout) { + snapshotFlow { inlineState.text.toString() } + .drop(1) + .collect { draft -> + val normalizedValue = normalize(draft) + if (validate(normalizedValue) == null) { + onValueSaved(normalizedValue) + } + } + } SettingCardItem( enabled = enabled, @@ -204,24 +195,12 @@ internal fun TextInputSettingCard( icon = icon, trailingContent = if (isWideLayout) { { - TextField( - value = inlineDraft, - onValueChange = { draft -> - inlineDraft = draft - val normalizedValue = normalize(draft) - if (validate(normalizedValue) == null) { - onValueSaved(normalizedValue) - } - }, + MillenniumTextField( + state = inlineState, + label = title, modifier = Modifier.width(inlineTextFieldWidth), - placeholder = if (placeholder.isNotEmpty()) { - { Text(placeholder) } - } else { - null - }, - singleLine = true, - enabled = enabled, - ) + placeholder = placeholder, + enabled = enabled) } } else { null @@ -234,54 +213,50 @@ internal fun TextInputSettingCard( ) if (!isWideLayout && isDialogOpen) { + val dialogState = rememberTextFieldState(value) var draft by remember(value, isDialogOpen) { mutableStateOf(value) } + + LaunchedEffect(dialogState) { + snapshotFlow { dialogState.text.toString() } + .drop(1) + .collect { draft = it } + } + val normalizedValue = normalize(draft) val errorMessage = validate(normalizedValue) - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = { isDialogOpen = false }, - title = { - Text(dialogTitle, style = MaterialTheme.typography.titleLarge) - }, - actions = { - PrimaryButton( + title = dialogTitle) { + if (dialogDescription != null) { + Text( + text = dialogDescription, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + MillenniumTextField( + state = dialogState, + label = title, + modifier = Modifier.fillMaxWidth(), + placeholder = placeholder, + error = errorMessage) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { if (errorMessage == null) { onValueSaved(normalizedValue) isDialogOpen = false } - } - ) { + }, + enabled = errorMessage == null) { Text(stringResource(Res.string.settings_action_save)) } - OutlineButton(onClick = { isDialogOpen = false }) { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.settings_action_cancel)) } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - dialogDescription?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = if (placeholder.isNotEmpty()) { - { Text(placeholder) } - } else { - null - }, - isError = errorMessage != null, - supportingText = errorMessage?.let { message -> - { Text(message) } - }, - singleLine = true, - ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt index 7e34554f..17c6d8f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt @@ -17,11 +17,11 @@ package dev.krtirtho.spotube.modules.settings.components -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.krtirtho.spotube.core.ui.base.ListRowTile /** * Reusable setting card item component for consistent styling @@ -37,7 +37,7 @@ fun SettingCardItem( onClick: () -> Unit = {}, modifier: Modifier = Modifier ) { - ListRowTile( + MillenniumListTile( selected = false, enabled = enabled, onClick = onClick, @@ -46,19 +46,18 @@ fun SettingCardItem( title = { Text( title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface ) }, subtitle = subtitle?.let { { Text( it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant ) } }, - trailing = trailingContent, - ) + trailing = trailingContent) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index 260cd7d3..19db935a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -23,20 +23,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,36 +38,37 @@ import androidx.compose.runtime.setValue 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 dev.krtirtho.spotube.core.ui.base.BaseSurface -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SurfaceRole -import dev.krtirtho.spotube.core.ui.base.SurfaceStyle -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme -import dev.krtirtho.spotube.modules.settings.AccentColors +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.colorFor import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.settings.recommendedAccents import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.theme.MillenniumColorSchemes +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig +import org.spotube.millennium.theme.resolve import spotube.composeapp.generated.resources.* internal fun LazyListScope.appearanceSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_appearance) } @@ -100,26 +95,20 @@ internal fun LazyListScope.appearanceSection( ) }, { - // Surface material and accent are one decision: each surface only offers the - // accents that are known to pair with it, so the result is always a curated, - // good-looking combination instead of an arbitrary one. AppearanceSettingCard( - selectedSurface = settings.surfaceTheme, - selectedAccent = settings.accentColor, + selectedPalette = settings.palette, icon = { SettingsItemIcon( Iconsax.IconsaxMagic, stringResource(Res.string.settings_appearance_title) ) }, - onSaved = { surface, accent -> + onSaved = { palette -> settingsViewModel.updateSettings { - copy(surfaceTheme = surface, accentColor = accent) + copy(palette = palette) } - }, - ) - }, - ) + }) + }) ) } @@ -133,197 +122,72 @@ private fun Theme.displayLabel(): String { } @Composable -private fun SurfaceStyle.displayLabel(): String { - return when (this) { - SurfaceStyle.Watery -> stringResource(Res.string.settings_surface_watery) - SurfaceStyle.Icy -> stringResource(Res.string.settings_surface_icy) - SurfaceStyle.Chrome -> stringResource(Res.string.settings_surface_chrome) - SurfaceStyle.Acrylic -> stringResource(Res.string.settings_surface_acrylic) - SurfaceStyle.Glass -> stringResource(Res.string.settings_surface_glass) - } -} - -@Composable -private fun AccentColors.displayLabel(): String { - return when (this) { - AccentColors.GREEN_GOBLIN -> stringResource(Res.string.settings_accent_green_goblin) - AccentColors.ELECTRIC_VIOLET -> stringResource(Res.string.settings_accent_electric_violet) - AccentColors.OCEANIC_CYAN -> stringResource(Res.string.settings_accent_oceanic_cyan) - AccentColors.SUNSET_ORANGE -> stringResource(Res.string.settings_accent_sunset_orange) - AccentColors.ROSE_GARDEN -> stringResource(Res.string.settings_accent_rose_garden) - AccentColors.MIDNIGHT_BLUE -> stringResource(Res.string.settings_accent_midnight_blue) - AccentColors.METALLIC_SLATE -> stringResource(Res.string.settings_accent_metallic_slate) - } +private fun MillenniumPalette.displayLabel(): String = when (this) { + MillenniumPalette.Blue -> stringResource(Res.string.settings_palette_blue) + MillenniumPalette.Orange -> stringResource(Res.string.settings_palette_orange) + MillenniumPalette.Lime -> stringResource(Res.string.settings_palette_lime) } @Composable private fun AppearanceSettingCard( - selectedSurface: SurfaceStyle, - selectedAccent: AccentColors, + selectedPalette: MillenniumPalette, icon: (@Composable () -> Unit)? = null, - onSaved: (SurfaceStyle, AccentColors) -> Unit, -) { + onSaved: (MillenniumPalette) -> Unit) { var isDialogOpen by remember { mutableStateOf(false) } - val isLight = LocalBaseUIColors.current.isLight SettingCardItem( title = stringResource(Res.string.settings_appearance_title), subtitle = stringResource( - Res.string.settings_appearance_subtitle_current, - selectedSurface.displayLabel(), - selectedAccent.displayLabel(), - ), + Res.string.settings_appearance_subtitle_palette, + selectedPalette.displayLabel()), icon = icon, trailingContent = { - AppearanceTrailingSwatch( - surface = selectedSurface, - accent = selectedAccent, - isLight = isLight, - ) + PaletteTrailingSwatch(selectedPalette) }, - onClick = { isDialogOpen = true }, - ) + onClick = { isDialogOpen = true }) if (isDialogOpen) { - var draftSurface by remember(selectedSurface, isDialogOpen) { - mutableStateOf(selectedSurface) - } - var draftAccent by remember(selectedSurface, selectedAccent, isDialogOpen) { - mutableStateOf(selectedAccent) + var draftPalette by remember(selectedPalette, isDialogOpen) { + mutableStateOf(selectedPalette) } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = { isDialogOpen = false }, - title = { - Text( - stringResource(Res.string.settings_appearance_dialog_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - actions = { - PrimaryButton( + title = stringResource(Res.string.settings_palette_dialog_title)) { + MillenniumText( + stringResource(Res.string.settings_palette_dialog_description), + color = AppTheme.colors.onSurfaceVariant) + MillenniumText( + stringResource(Res.string.settings_palette_label), + color = AppTheme.colors.onSurface) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumPalette.entries.forEach { palette -> + PaletteSwatch( + palette = palette, + label = palette.displayLabel(), + selected = palette == draftPalette, + onClick = { draftPalette = palette }) + } + } + + PalettePreview(draftPalette) + + MillenniumSeparator() + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { - onSaved(draftSurface, draftAccent) + onSaved(draftPalette) isDialogOpen = false - } - ) { - Text(stringResource(Res.string.settings_action_save)) + }) { + MillenniumText(stringResource(Res.string.settings_action_save)) } - OutlineButton(onClick = { isDialogOpen = false }) { - Text(stringResource(Res.string.settings_action_cancel)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text( - text = stringResource(Res.string.settings_appearance_dialog_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = stringResource(Res.string.settings_appearance_surface_label), - style = MaterialTheme.typography.labelLarge, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - SurfaceStyle.entries.forEach { style -> - SurfaceStyleTile( - style = style, - accent = draftAccent, - isLight = isLight, - selected = style == draftSurface, - modifier = Modifier.weight(1f), - onClick = { - draftSurface = style - if (draftAccent !in style.recommendedAccents) { - draftAccent = style.recommendedAccents.first() - } - }, - ) - } - } - - Text( - text = stringResource(Res.string.settings_appearance_accent_label), - style = MaterialTheme.typography.labelLarge, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - draftSurface.recommendedAccents.forEach { accent -> - AccentSwatch( - accent = accent, - label = accent.displayLabel(), - isLight = isLight, - selected = accent == draftAccent, - onClick = { draftAccent = accent }, - ) - } - } - - AppearancePreview( - surface = draftSurface, - accent = draftAccent, - isLight = isLight, - ) - } - } - } -} - -@Composable -private fun SurfaceStyleTile( - style: SurfaceStyle, - accent: AccentColors, - isLight: Boolean, - selected: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - val scheme = remember(style, accent, isLight) { - baseUIColorScheme(style, isLight, accent.colorFor(isLight)) - } - Box( - modifier = modifier - .height(70.dp) - .then( - if (selected) { - Modifier - .border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(14.dp)) - .padding(2.dp) - } else { - Modifier.padding(2.dp) - } - ), - ) { - CompositionLocalProvider(LocalBaseUIColors provides scheme) { - BaseSurface( - style = style, - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick), - contentPadding = PaddingValues(8.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = style.displayLabel(), - style = MaterialTheme.typography.labelMedium, - ) - Box( - modifier = Modifier - .size(14.dp) - .background(accent.colorFor(isLight), CircleShape), - ) + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.settings_action_cancel)) } } } @@ -331,126 +195,65 @@ private fun SurfaceStyleTile( } @Composable -private fun AccentSwatch( - accent: AccentColors, +private fun PaletteSwatch( + palette: MillenniumPalette, label: String, - isLight: Boolean, selected: Boolean, - onClick: () -> Unit, -) { + onClick: () -> Unit) { + val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } Box( modifier = Modifier - .size(34.dp) + .size(40.dp) .clip(CircleShape) - .background(accent.colorFor(isLight)) + .background(scheme.accent) .border( width = if (selected) 3.dp else 1.dp, - color = if (selected) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.outlineVariant - }, - shape = CircleShape, - ) + color = if (selected) AppTheme.colors.onSurface else AppTheme.colors.outlineVariant, + shape = CircleShape) .clickable(onClick = onClick) - .semantics { contentDescription = label }, - ) + .semantics { contentDescription = label }) } @Composable -private fun AppearanceTrailingSwatch( - surface: SurfaceStyle, - accent: AccentColors, - isLight: Boolean, -) { - val scheme = remember(surface, accent, isLight) { - baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) - } - CompositionLocalProvider(LocalBaseUIColors provides scheme) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.size(width = 46.dp, height = 28.dp), - ) {} - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryAccent, - shape = CircleShape, - modifier = Modifier.size(18.dp), - ) {} - } +private fun PaletteTrailingSwatch(palette: MillenniumPalette) { + val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(width = 46.dp, height = 28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(scheme.accent.copy(alpha = 0.35f))) + Box( + Modifier + .size(18.dp) + .clip(CircleShape) + .background(scheme.accent)) } } @Composable -private fun AppearancePreview( - surface: SurfaceStyle, - accent: AccentColors, - isLight: Boolean, -) { - val scheme = remember(surface, accent, isLight) { - baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) - } - CompositionLocalProvider(LocalBaseUIColors provides scheme) { - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(16.dp), +private fun PalettePreview(palette: MillenniumPalette) { + val dark = AppTheme.colors.background.luminance() < 0.5f + MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) { + Column( modifier = Modifier .fillMaxWidth() - .height(150.dp), - contentPadding = PaddingValues(14.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = stringResource(Res.string.settings_preview_label), - style = MaterialTheme.typography.labelLarge, - ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryAccent, - shape = RoundedCornerShape(999.dp), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - stringResource(Res.string.settings_preview_button), - style = MaterialTheme.typography.labelMedium, - ) - } - BaseSurface( - style = surface, - role = SurfaceRole.SecondaryAccent, - shape = RoundedCornerShape(999.dp), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - stringResource(Res.string.settings_preview_secondary), - style = MaterialTheme.typography.labelMedium, - ) - } + .clip(RoundedCornerShape(14.dp)) + .background(MillenniumTheme.colors.surface) + .border(1.dp, MillenniumTheme.colors.border, RoundedCornerShape(14.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumText( + stringResource(Res.string.settings_preview_label), + color = MillenniumTheme.colors.muted) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = {}) { + MillenniumText(stringResource(Res.string.settings_preview_button)) } - BaseSurface( - style = surface, - role = SurfaceRole.SecondaryBackground, - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentPadding = PaddingValues(10.dp), - ) { - Text( - text = stringResource(Res.string.settings_preview_label), - style = MaterialTheme.typography.bodySmall, - ) + MillenniumButton(onClick = {}, style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.settings_preview_secondary)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt index c43fc60a..581d4cdd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt @@ -17,14 +17,17 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.Folder import compose.icons.feathericons.HardDrive import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.components.SettingCardItem @@ -42,8 +45,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.cacheSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_caching) } @@ -122,8 +124,7 @@ internal fun LazyListScope.cacheSection( } } ) - }, - ) + }) ) } @@ -131,8 +132,7 @@ internal fun LazyListScope.cacheSection( private fun CacheFolderSettingCard( enabled: Boolean, folder: String?, - onFolderSelected: (String?) -> Unit, -) { + onFolderSelected: (String?) -> Unit) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> if (directory != null) { val normalized = normalizePath(directory.path) @@ -154,17 +154,13 @@ private fun CacheFolderSettingCard( ) }, trailingContent = { - GhostIconButton( + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_cache_folder_title), onClick = { pickerLauncher.launch() }, enabled = enabled, - ) { - Icon( - Iconsax.IconsaxFolderAdd, - contentDescription = stringResource(Res.string.settings_cache_folder_title) - ) - } + style = MillenniumIconButtonStyle.Quiet) }, onClick = { pickerLauncher.launch() }, - enabled = enabled, - ) + enabled = enabled) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt index 3095f488..0874de79 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt @@ -30,8 +30,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.desktopSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_desktop) } @@ -72,8 +71,7 @@ internal fun LazyListScope.desktopSection( } } ) - }, - ) + }) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index 4acd6832..62350c83 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,9 +31,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -37,10 +44,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard @@ -79,13 +82,11 @@ import spotube.composeapp.generated.resources.settings_subtitle_current internal fun LazyListScope.downloadsSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) val downloadQualities = availableAudioQualities( format = settings.downloadMusicFormat, - current = settings.downloadMusicQuality, - ) + current = settings.downloadMusicQuality) if (includeHeader) { settingsSectionHeader(Res.string.settings_section_downloads) @@ -129,8 +130,7 @@ internal fun LazyListScope.downloadsSection( settingsViewModel.updateSettings { copy( downloadMusicFormat = format, - downloadMusicQuality = format.resolveQuality(downloadMusicQuality), - ) + downloadMusicQuality = format.resolveQuality(downloadMusicQuality)) } } ) @@ -154,16 +154,14 @@ internal fun LazyListScope.downloadsSection( } } ) - }, - ) + }) ) } @Composable private fun DownloadFolderSettingCard( folder: String?, - onFolderSelected: (String?) -> Unit, -) { + onFolderSelected: (String?) -> Unit) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> if (directory != null) { val normalized = normalizePath(directory.path) @@ -182,12 +180,11 @@ private fun DownloadFolderSettingCard( SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title)) }, trailingContent = { - GhostIconButton(onClick = { pickerLauncher.launch() }) { - Icon( - imageVector = Iconsax.IconsaxFolderAdd, - contentDescription = stringResource(Res.string.settings_download_folder_title) - ) - } + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_download_folder_title), + onClick = { pickerLauncher.launch() }, + style = MillenniumIconButtonStyle.Quiet) }, onClick = { pickerLauncher.launch() @@ -198,8 +195,7 @@ private fun DownloadFolderSettingCard( @Composable private fun LocalMediaFoldersSettingCard( folders: List, - onFoldersSaved: (List) -> Unit, -) { + onFoldersSaved: (List) -> Unit) { var isDialogOpen by remember { mutableStateOf(false) } val subtitlePreview = when { @@ -207,8 +203,7 @@ private fun LocalMediaFoldersSettingCard( folders.size == 1 -> folders.first() else -> stringResource( Res.string.settings_local_media_folders_subtitle_current, - folders.size, - ) + folders.size) } SettingCardItem( @@ -217,11 +212,12 @@ private fun LocalMediaFoldersSettingCard( icon = { SettingsItemIcon( Iconsax.IconsaxFolder, - stringResource(Res.string.settings_local_media_folders_title), - ) + stringResource(Res.string.settings_local_media_folders_title)) }, trailingContent = { - OutlineButton(onClick = { isDialogOpen = true }) { + MillenniumButton( + onClick = { isDialogOpen = true }, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.settings_local_media_folders_manage)) } }, @@ -234,8 +230,7 @@ private fun LocalMediaFoldersSettingCard( LocalMediaFoldersDialog( folders = folders, onDismiss = { isDialogOpen = false }, - onFoldersSaved = onFoldersSaved, - ) + onFoldersSaved = onFoldersSaved) } } @@ -243,8 +238,7 @@ private fun LocalMediaFoldersSettingCard( private fun LocalMediaFoldersDialog( folders: List, onDismiss: () -> Unit, - onFoldersSaved: (List) -> Unit, -) { + onFoldersSaved: (List) -> Unit) { val draftFolders = remember(folders) { mutableStateListOf().apply { addAll(folders) } } @@ -257,41 +251,23 @@ private fun LocalMediaFoldersDialog( } } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(Res.string.settings_local_media_folders_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - actions = { - PrimaryButton( - onClick = { - onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) - onDismiss() - } - ) { - Text(stringResource(Res.string.settings_action_save)) - } - OutlineButton(onClick = onDismiss) { - Text(stringResource(Res.string.settings_action_cancel)) - } - }, - ) { + title = stringResource(Res.string.settings_local_media_folders_title)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = stringResource(Res.string.settings_local_media_folders_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) - OutlineButton(onClick = { pickerLauncher.launch() }) { + MillenniumButton( + onClick = { pickerLauncher.launch() }, + style = MillenniumButtonStyle.Secondary) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Iconsax.IconsaxAddSquare, contentDescription = null) + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon(Iconsax.IconsaxAddSquare, contentDescription = null) Text(stringResource(Res.string.settings_local_media_folders_add_action)) } } @@ -300,42 +276,52 @@ private fun LocalMediaFoldersDialog( Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Text( text = stringResource(Res.string.settings_local_media_folders_none_added), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } else { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { draftFolders.forEachIndexed { index, folder -> Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = folder, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) - GhostIconButton( + style = AppTheme.typography.bodySmall) + MillenniumIconButton( onClick = { if (index in draftFolders.indices) { draftFolders.removeAt(index) } - } - ) { - Icon( + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxTrash, contentDescription = stringResource( - Res.string.settings_local_media_folders_remove_action, - ), - ) + Res.string.settings_local_media_folders_remove_action)) } } } } } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = { + onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) + onDismiss() + }) { + Text(stringResource(Res.string.settings_action_save)) + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.settings_action_cancel)) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt index 9feaedf3..1c8886de 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -9,9 +9,11 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight import dev.krtirtho.spotube.core.navigation.NavigationCommands @@ -28,8 +30,7 @@ import spotube.composeapp.generated.resources.settings_section_jam internal fun LazyListScope.jamSection( settings: UserSettings, - navigationCommands: NavigationCommands, -) { + navigationCommands: NavigationCommands) { settingsSectionHeader(Res.string.settings_section_jam) settingsSectionCard( items = listOf( @@ -39,24 +40,19 @@ internal fun LazyListScope.jamSection( subtitle = stringResource( Res.string.settings_jam_broker_host_subtitle, settings.jamBroker.host.ifBlank { "—" }, - settings.jamBroker.port, - ), + settings.jamBroker.port), icon = { SettingsItemIcon( Iconsax.CustomServer, - stringResource(Res.string.settings_jam_broker_title), - ) + stringResource(Res.string.settings_jam_broker_title)) }, trailingContent = { - Icon( + MillenniumIcon( imageVector = FeatherIcons.ChevronRight, contentDescription = stringResource(Res.string.settings_jam_broker_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) }, - onClick = { navigationCommands.navigateTo(Routes.JamSettings) }, - ) - }, - ) + onClick = { navigationCommands.navigateTo(Routes.JamSettings) }) + }) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt index 046a28df..a4795ff1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -35,8 +35,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.languageRegionSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_language_region) } @@ -65,8 +64,7 @@ internal fun LazyListScope.languageRegionSection( copy(language = value) } }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, - ) + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) }, { SelectionSettingCard( @@ -91,10 +89,8 @@ internal fun LazyListScope.languageRegionSection( copy(country = value) } }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, - ) - }, - ) + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) + }) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 0b4f515b..71b77ad6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -17,9 +17,11 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight @@ -53,13 +55,11 @@ internal fun LazyListScope.playbackSection( navigatorCommands: NavigationCommands, requestLocalNetworkPermission: () -> Unit, includeBlacklist: Boolean = true, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( format = settings.streamingMusicFormat, - current = settings.streamingMusicQuality, - ) + current = settings.streamingMusicQuality) val cards: List<@Composable () -> Unit> = buildList { add { @@ -79,8 +79,7 @@ internal fun LazyListScope.playbackSection( settingsViewModel.updateSettings { copy( streamingMusicFormat = format, - streamingMusicQuality = format.resolveQuality(streamingMusicQuality), - ) + streamingMusicQuality = format.resolveQuality(streamingMusicQuality)) } } ) @@ -223,20 +222,17 @@ internal fun LazyListScope.playbackSection( icon = { SettingsItemIcon( Iconsax.IconsaxForbidden, - stringResource(Res.string.settings_blacklist_title), - ) + stringResource(Res.string.settings_blacklist_title)) }, trailingContent = { - Icon( + MillenniumIcon( imageVector = FeatherIcons.ChevronRight, contentDescription = stringResource(Res.string.settings_blacklist_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) }, onClick = { navigatorCommands.navigateTo(Routes.Blacklist) - }, - ) + }) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt index 2258f117..e4a44a6c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt @@ -17,10 +17,12 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import compose.icons.FeatherIcons @@ -51,10 +53,10 @@ internal fun LazyListScope.pluginsSection( ) }, trailingContent = { - Icon( + MillenniumIcon( imageVector = FeatherIcons.ChevronRight, contentDescription = stringResource(Res.string.settings_plugins_manage_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) }, onClick = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt index f81cf926..04b853dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt @@ -32,8 +32,7 @@ import spotube.composeapp.generated.resources.settings_updates_auto_check_title internal fun LazyListScope.updatesSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_updates) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt index b2e99aa7..affcb156 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt @@ -17,19 +17,26 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight @@ -38,64 +45,52 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import dev.krtirtho.spotube.core.ui.base.Card private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), -) + AudioQuality.Lossy(bitrate = 256_000)) internal val streamingFormatPresets = listOf( AudioFormat( codec = "opus", container = "webm", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "aac", container = "mp4", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "vorbis", container = "ogg", - qualities = standardLossyQualities, - ), -) + qualities = standardLossyQualities)) internal val downloadFormatPresets = listOf( AudioFormat( codec = "aac", container = "mp4", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "mp3", container = "mp3", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "opus", container = "webm", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "flac", container = "flac", qualities = listOf( AudioQuality.Lossless(sampleRate = 44_100, channels = 2), - AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ), - ), -) + AudioQuality.Lossless(sampleRate = 48_000, channels = 2)))) internal fun LazyListScope.settingsSectionHeader(title: StringResource) { item { Text( text = stringResource(title), - style = MaterialTheme.typography.labelMedium, + style = AppTheme.typography.labelMedium, color = Color.Gray, modifier = Modifier .fillMaxWidth() @@ -112,9 +107,9 @@ internal fun LazyListScope.settingsSectionHeader(title: StringResource) { internal fun SettingsPageTitle(title: StringResource) { Text( text = stringResource(title), - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + color = AppTheme.colors.onSurface, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) @@ -125,18 +120,16 @@ internal fun SettingsPageTitle(title: StringResource) { internal fun SettingsItemIcon( imageVector: ImageVector, contentDescription: String, - tint: Color = MaterialTheme.colorScheme.primary, -) { - Surface( - shape = RoundedCornerShape(8.dp), - color = tint.copy(alpha = 0.12f), - ) { + tint: Color = AppTheme.colors.primary) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(tint.copy(alpha = 0.12f))) { Box(modifier = Modifier.padding(8.dp)) { - Icon( + MillenniumIcon( imageVector = imageVector, contentDescription = contentDescription, - tint = tint, - ) + tint = tint) } } } @@ -194,27 +187,25 @@ internal fun normalizePath(path: String): String { } internal fun LazyListScope.settingsSectionCard( - items: List<@Composable () -> Unit>, -) { + items: List<@Composable () -> Unit>) { item { - Card( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.medium, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 6.dp) + .padding(top = 4.dp, bottom = 4.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 4.dp) - ) { - items.forEachIndexed { index, content -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - content() + items.forEachIndexed { index, content -> + if (index > 0) { + MillenniumSeparator() } + content() } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt index 561704f3..c7e4cd30 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt @@ -17,99 +17,45 @@ package dev.krtirtho.spotube.modules.shell -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumBottomNavigationBar +import org.spotube.millennium.composables.MillenniumBottomNavigationItem +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.bottombarTabs import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.BaseSurface -import dev.krtirtho.spotube.core.ui.base.SurfaceRole import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator @Composable fun AppBottombar( navigator: Navigator, navigationState: NavigationState, - modifier: Modifier = Modifier, -) { - BaseSurface( - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), - modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(80.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - bottombarTabs.filterIsInstance>() - .forEach { tab -> - val selected = navigationState.topLevelRoute == tab.route - val labelColor = if (selected) { - MaterialTheme.colorScheme.primary + modifier: Modifier = Modifier) { + MillenniumBottomNavigationBar(modifier = modifier) { + bottombarTabs.filterIsInstance>() + .forEach { tab -> + val selected = navigationState.topLevelRoute == tab.route + MillenniumBottomNavigationItem( + selected = selected, + onClick = { navigator.navigate(tab.route) }, + contentDescription = tab.title, + icon = { + MillenniumIcon( + imageVector = tab.icon, + contentDescription = null, + tint = LocalTextStyle.current.color) + }, + label = { Text(tab.title) }, + badge = if (tab.route == Routes.Library) { + { DownloadBadgeIndicator() } } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - Column( - modifier = Modifier - .clip(CircleShape) - .clickable { navigator.navigate(tab.route) } - .padding(8.dp) - .size(56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Box { - Icon( - imageVector = tab.icon, - contentDescription = tab.title, - tint = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - if (tab.route == Routes.Library) { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = tab.title, - style = MaterialTheme.typography.labelSmall, - fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, - color = labelColor, - ) - } - } - } + null + }) + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index c43837fd..df23acf1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -17,6 +17,8 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -28,12 +30,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -43,16 +48,23 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import androidx.compose.ui.graphics.SolidColor + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -82,12 +94,6 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.Slider -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadStatus import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel @@ -121,7 +127,6 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import kotlin.time.Duration.Companion.milliseconds - // The expanded player on small screens // on the top left it has a angle/chevron down that can be used to collapse the player back to the // floating player @@ -135,7 +140,6 @@ import kotlin.time.Duration.Companion.milliseconds // below that we have the playback controls and the progress bar, which should be similar to the // large player but with a different layout to fit the smaller screen // And at the very end we will have the volume slider -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppExpandedPlayer( modifier: Modifier = Modifier, @@ -154,8 +158,7 @@ fun AppExpandedPlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), -) { + )) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val jamRoomService: JamRoomService = koinInject() val isJamGuest by jamRoomService.role @@ -175,17 +178,17 @@ fun AppExpandedPlayer( downloads.lastOrNull { it.track?.id == t.id } } } - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostState = remember { MillenniumSnackbarHostState() } val albumArtModifier = rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - val moreOptionsSheetState = rememberModalBottomSheetState() + val moreOptionsSheetState = rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded) PlayerOptionDialogs(viewModel = playerOptionsViewModel) - LaunchedEffect(playerUiState.progress, isSeeking) { if (!isSeeking) { seekProgress = playerUiState.progress @@ -235,16 +238,23 @@ fun AppExpandedPlayer( } } - BaseScaffold( - modifier = modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - ) { innerPadding -> + LaunchedEffect(moreOptionsSheetState.bottomSheetState.currentDetent) { + if (moreOptionsSheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + playerOptionsViewModel.dismissMoreOptionsSheet() + } + } + + LaunchedEffect(playerOptionsUiState.isMoreOptionsSheetOpen) { if (playerOptionsUiState.isMoreOptionsSheetOpen) { - ModalBottomSheet( - onDismissRequest = { - playerOptionsViewModel.dismissMoreOptionsSheet() - }, - sheetState = moreOptionsSheetState + moreOptionsSheetState.animateTo(SheetDetent.FullyExpanded) + } + } + + Box(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { + MillenniumModalBottomSheet( + state = moreOptionsSheetState, + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp) ) { // Grid of Options LazyVerticalGrid( @@ -252,8 +262,7 @@ fun AppExpandedPlayer( contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { item { OptionTile( icon = Iconsax.SwapHorizontal2, @@ -261,8 +270,7 @@ fun AppExpandedPlayer( onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() onAlternativeSource() - }, - ) + }) } item { val download = currentDownload @@ -276,14 +284,20 @@ fun AppExpandedPlayer( null -> "Download" } val statusColor = when (status) { - is DownloadStatus.Completed -> MaterialTheme.colorScheme.primary - is DownloadStatus.Failed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurfaceVariant } - Surface( + Box( modifier = Modifier .aspectRatio(1f) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 2.dp) .clickable { playerOptionsViewModel.dismissMoreOptionsSheet() when (status) { @@ -317,26 +331,20 @@ fun AppExpandedPlayer( } } onDownloadTrack() - }, - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 1.dp, - ) { + }) { Column( modifier = Modifier .fillMaxSize() .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { if (status is DownloadStatus.Downloading) { DownloadProgressIcon( track = currentTrack, icon = Iconsax.IconsaxDirectboxReceive, contentDescription = "Download", modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) + tint = AppTheme.colors.onSurface) } else { val icon = when (status) { is DownloadStatus.Completed -> Iconsax.IconsaxCheckCircle @@ -345,26 +353,24 @@ fun AppExpandedPlayer( is DownloadStatus.Queued -> Iconsax.InconsaxClock null -> Iconsax.IconsaxDirectboxReceive } - Icon( + MillenniumIcon( imageVector = icon, contentDescription = statusLabel, modifier = Modifier.size(28.dp), tint = when (status) { - is DownloadStatus.Completed -> MaterialTheme.colorScheme.primary - is DownloadStatus.Failed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurface - }, - ) + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurface + }) } Spacer(modifier = Modifier.height(8.dp)) Text( text = statusLabel, - style = MaterialTheme.typography.labelSmall, + style = AppTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, - color = statusColor, - ) + color = statusColor) } } } @@ -376,8 +382,7 @@ fun AppExpandedPlayer( playerOptionsViewModel.dismissMoreOptionsSheet() playerOptionsViewModel.showSleepTimerDialog() onSleepTimer() - }, - ) + }) } item { OptionTile( @@ -390,8 +395,7 @@ fun AppExpandedPlayer( navigationCommands.navigateTo(Routes.Album(albumId)) } onGoToAlbum() - }, - ) + }) } item { OptionTile( @@ -400,8 +404,7 @@ fun AppExpandedPlayer( onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() playerOptionsViewModel.showTrackDetails() - }, - ) + }) } } } @@ -410,29 +413,31 @@ fun AppExpandedPlayer( Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton(onClick = onCollapse) { - Icon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") + MillenniumIconButton( + onClick = onCollapse, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") } Text( text = "Now Playing", - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, - maxLines = 1, - ) - GhostIconButton(onClick = playerOptionsViewModel::showMoreOptionsSheet) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") + maxLines = 1) + MillenniumIconButton( + onClick = playerOptionsViewModel::showMoreOptionsSheet, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") } } @@ -440,22 +445,20 @@ fun AppExpandedPlayer( modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 24.dp), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Box( modifier = Modifier .fillMaxWidth(0.84f) .aspectRatio(1f) .then(albumArtModifier) .clip(RoundedCornerShape(22.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .background(AppTheme.colors.surfaceContainerHighest) ) { AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } } @@ -468,38 +471,34 @@ fun AppExpandedPlayer( ) { PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, - ) + savedTracksViewModel = savedTracksViewModel) Column( modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Text( text = playerUiState.title, - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) + textAlign = TextAlign.Center) Text( text = playerUiState.artists, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) + textAlign = TextAlign.Center) } - IconButton( + MillenniumIconButton( onClick = onQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape) + style = MillenniumIconButtonStyle.Secondary ) { - Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") } } Column(modifier = Modifier.padding(top = 20.dp)) { - Slider( + MillenniumSlider( value = seekProgress, onValueChange = { seekProgress = it @@ -510,6 +509,7 @@ fun AppExpandedPlayer( onSeekFinished() }, enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + label = "Seek", modifier = Modifier.fillMaxWidth() ) Row( @@ -517,57 +517,61 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(top = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = formatPlayerTime(playerUiState.position), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) Text( text = formatPlayerTime(playerUiState.displayDuration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } Row( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp, bottom = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) { - Icon( + verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + onClick = ::onShuffleToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", tint = if (playerUiState.isShuffling) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } ) } - GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } - IconButton( + MillenniumIconButton( onClick = ::onPlayPause, - theme = invertedButtonStyle().copyShape(CircleShape), - modifier = Modifier - .size(72.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Primary) { + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(30.dp), - ) + modifier = Modifier.size(30.dp)) } - GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = "Next") } - GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) { - Icon( + MillenniumIconButton( + onClick = ::onLoopToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.ONE -> Iconsax.IconsaxRepeateOne @@ -575,9 +579,9 @@ fun AppExpandedPlayer( }, contentDescription = "Loop mode ${playerUiState.loopState.name}", tint = if (playerUiState.loopState == LoopState.NONE) { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } else { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } ) } @@ -589,9 +593,12 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(bottom = 16.dp), audioPlayer = audioPlayer, - onExpand = onExpandLyrics, - ) + onExpand = onExpandLyrics) } + + MillenniumSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter)) } } @@ -599,15 +606,13 @@ fun AppExpandedPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, -): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) } } @@ -649,33 +654,29 @@ private fun LyricsPreviewCard( if (syncedLyrics.isEmpty() || isLoading) return - Surface( + Box( modifier = modifier - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onExpand), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - tonalElevation = 2.dp, - ) { + .millenniumSurface( + background = SolidColor(AppTheme.colors.surfaceContainerHighest), + shape = RoundedCornerShape(16.dp), + shadowElevation = 2.dp) + .clickable(onClick = onExpand)) { Column(modifier = Modifier.padding(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = "Lyrics", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - GhostIconButton( + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) + MillenniumIconButton( onClick = onExpand, - modifier = Modifier.size(28.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxArrowSquareUp, contentDescription = "Expand lyrics", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } @@ -685,8 +686,7 @@ private fun LyricsPreviewCard( .fillMaxWidth() .height(120.dp), contentPadding = PaddingValues(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { val displayLines = syncedLyrics.take(6) itemsIndexed(displayLines) { index, line -> val isCurrent = @@ -696,18 +696,17 @@ private fun LyricsPreviewCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp), - style = MaterialTheme.typography.bodySmall.copy( + style = AppTheme.typography.bodySmall.copy( fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, color = if (isCurrent) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f) } ), maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) + textAlign = TextAlign.Center) } } } @@ -719,38 +718,30 @@ private fun OptionTile( icon: ImageVector, label: String, onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( + modifier: Modifier = Modifier) { + MillenniumCard( modifier = modifier .aspectRatio(1f) .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 1.dp, - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = icon, - contentDescription = label, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + contentPadding = PaddingValues(12.dp)) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + MillenniumIcon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurface) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + style = AppTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = AppTheme.colors.onSurfaceVariant) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt index b628831c..3c7bcab8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.ui.graphics.SolidColor import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -33,10 +40,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -68,11 +74,6 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.BaseSurface -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.SurfaceRole // The floating player on small screens that appears above the floating AppBottombar // @@ -93,8 +94,7 @@ fun AppFloatingPlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), -) { + )) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val scope = rememberCoroutineScope() val albumArtModifier = @@ -116,21 +116,18 @@ fun AppFloatingPlayer( } } - BaseSurface( - role = SurfaceRole.InverseBackground, - shape = RoundedCornerShape(24.dp, 24.dp), + Box( modifier = modifier .fillMaxWidth() - .height(76.dp), - ) { + .height(76.dp) + .millenniumSurface( + background = SolidColor(AppTheme.colors.inverseSurface), + shape = RoundedCornerShape(24.dp, 24.dp))) { Box( modifier = Modifier - .fillMaxSize(), - ) { - LinearProgressIndicator( - progress = { seekProgress.coerceIn(0f, 1f) }, - color = MaterialTheme.colorScheme.inverseOnSurface, - trackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.22f), + .fillMaxSize()) { + MillenniumProgress( + progress = seekProgress.coerceIn(0f, 1f), modifier = Modifier .fillMaxWidth() .height(2.dp) @@ -154,28 +151,27 @@ fun AppFloatingPlayer( .size(48.dp) .then(albumArtModifier) .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + .background(AppTheme.colors.onSurface.copy(alpha = 0.12f)) ) { AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier .fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } Column(modifier = Modifier.padding(start = 10.dp)) { Text( text = playerUiState.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.inverseOnSurface, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.inverseOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = playerUiState.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.72f), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.inverseOnSurface.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -185,20 +181,15 @@ fun AppFloatingPlayer( Row(verticalAlignment = Alignment.CenterVertically) { PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, - ) + savedTracksViewModel = savedTracksViewModel) Spacer(modifier = Modifier.size(8.dp)) - IconButton( + MillenniumIconButton( onClick = ::onPlayPause, enabled = playerUiState.queue.isNotEmpty(), - theme = LocalBaseUITheme.current.iconButtons.outline.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) - ) { - Icon( + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", - ) + contentDescription = if (playerUiState.isPlaying) "Pause" else "Play") } } } @@ -210,14 +201,12 @@ fun AppFloatingPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, -): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 1adb2dbb..fd274a6e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSlider import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -33,13 +40,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,6 +61,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.composeunstyled.AnchorSide import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect @@ -69,15 +74,6 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.Slider -import dev.krtirtho.spotube.core.ui.base.SurfaceRole -import dev.krtirtho.spotube.core.ui.base.VariableIconButton -import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant -import dev.krtirtho.spotube.core.ui.base.baseSurfaceTexture -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID @@ -114,7 +110,6 @@ import spotube.composeapp.generated.resources.player_sleep_timer import spotube.composeapp.generated.resources.player_track_details import kotlin.time.Duration.Companion.milliseconds - // The main player on large screens shown at the bottom of the screen permanently. It should be // below the sidebar. And should be similarly give a floating vibe with a translucent background // just like the AppBottombar. @@ -138,7 +133,7 @@ fun AppLargePlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), + ) ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val jamRoomService: JamRoomService = koinInject() @@ -214,19 +209,13 @@ fun AppLargePlayer( // A translucent tint (not near-opaque) so the blurred art behind still reads through, and // the material's watery caustics sit on top of it. - val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) + val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f) - Surface( + Column( modifier = modifier - .windowInsetsPadding(WindowInsets.navigationBars), - color = Color.Transparent, - shadowElevation = 0.dp, + .windowInsetsPadding(WindowInsets.navigationBars) ) { - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = Color.Gray, - thickness = 1.dp, - ) + MillenniumSeparator(modifier = Modifier.fillMaxWidth()) Box( modifier = Modifier .fillMaxWidth() @@ -238,7 +227,6 @@ fun AppLargePlayer( ) } } - .baseSurfaceTexture(SurfaceRole.PrimaryBackground) ) { Row( modifier = Modifier @@ -255,26 +243,26 @@ fun AppLargePlayer( modifier = Modifier .size(52.dp) .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .background(AppTheme.colors.surfaceContainerHighest) ) { AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, + contentScale = ContentScale.Crop ) } Column(modifier = Modifier.padding(start = 12.dp)) { Text( text = playerUiState.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = playerUiState.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -282,7 +270,7 @@ fun AppLargePlayer( Spacer(modifier = Modifier.width(10.dp)) PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, + savedTracksViewModel = savedTracksViewModel ) } @@ -290,7 +278,7 @@ fun AppLargePlayer( modifier = Modifier.weight(1.1f), horizontalAlignment = Alignment.CenterHorizontally ) { - Slider( + MillenniumSlider( value = seekProgress, onValueChange = { seekProgress = it @@ -301,7 +289,8 @@ fun AppLargePlayer( onSeekFinished() }, enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, - modifier = Modifier.fillMaxWidth(), + label = "Seek", + modifier = Modifier.fillMaxWidth() ) Row( modifier = Modifier.fillMaxWidth(), @@ -310,13 +299,13 @@ fun AppLargePlayer( ) { Text( text = formatPlayerTime(playerUiState.position), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) Text( text = formatPlayerTime(playerUiState.displayDuration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } Row( @@ -324,44 +313,50 @@ fun AppLargePlayer( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - VariableIconButton( + MillenniumIconButton( onClick = ::onShuffleToggle, enabled = !isJamGuest, - variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost + style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet ) { - Icon( + MillenniumIcon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", tint = if (playerUiState.isShuffling) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } ) } - GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = ::onPlayPause, - theme = invertedButtonStyle().copyShape(CircleShape), - modifier = Modifier - .size(50.dp), + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet ) { - Icon( + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + } + MillenniumIconButton( + onClick = ::onPlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", + contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause" ) } - GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = "Next") } - VariableIconButton( + MillenniumIconButton( onClick = ::onLoopToggle, enabled = !isJamGuest, - variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline + style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.ONE -> Iconsax.IconsaxRepeateOne @@ -369,9 +364,9 @@ fun AppLargePlayer( }, contentDescription = "Loop mode ${playerUiState.loopState.name}", tint = if (playerUiState.loopState == LoopState.NONE) { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } else { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } ) } @@ -383,88 +378,86 @@ fun AppLargePlayer( horizontalAlignment = Alignment.End ) { Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton(onClick = onQueue) { - Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onQueue + ) { + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") } - GhostIconButton(onClick = { - val track = (currentEntry as? QueueEntry.StreamingTrack)?.track - if (track != null) { - downloadsViewModel.downloadTrack(track) - } - }) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + val track = (currentEntry as? QueueEntry.StreamingTrack)?.track + if (track != null) { + downloadsViewModel.downloadTrack(track) + } + }) { DownloadProgressIcon( track = (currentEntry as? QueueEntry.StreamingTrack)?.track, icon = Iconsax.IconsaxDirectboxReceive, - contentDescription = "Download", + contentDescription = "Download" ) } - GhostIconButton(onClick = onAlternativeSource) { - Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source") + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onAlternativeSource + ) { + MillenniumIcon( + Iconsax.SwapHorizontal2, + contentDescription = "Alternative source" + ) } - GhostIconButton(onClick = onLyrics) { - Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onLyrics + ) { + MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") } - Box { - GhostIconButton( - onClick = { - playerOptionsViewModel.showMoreOptionsMenu() - onMoreOptions() - } - ) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") - } - DropdownMenu( - expanded = playerOptionsUiState.isMoreOptionsMenuOpen, - onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu, - ) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.player_go_to_album)) }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxCd, - contentDescription = null, - ) - }, - enabled = currentTrack?.album?.id != null, - onClick = { + val goToAlbumLabel = stringResource(Res.string.player_go_to_album) + val sleepTimerLabel = stringResource(Res.string.player_sleep_timer) + val trackDetailsLabel = stringResource(Res.string.player_track_details) + MillenniumDropdownMenu( + expanded = playerOptionsUiState.isMoreOptionsMenuOpen, + onExpandedChange = { expanded -> + if (!expanded) playerOptionsViewModel.dismissMoreOptionsMenu() + }, + items = listOf(goToAlbumLabel, sleepTimerLabel, trackDetailsLabel), + onItemClick = { label -> + when (label) { + goToAlbumLabel -> { playerOptionsViewModel.dismissMoreOptionsMenu() currentTrack?.album?.id?.let { albumId -> navigationCommands.navigateTo(Routes.Album(albumId)) } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.player_sleep_timer)) }, - leadingIcon = { - Icon( - imageVector = Iconsax.InconsaxClock, - contentDescription = null, - ) - }, - onClick = { + } + + sleepTimerLabel -> { playerOptionsViewModel.dismissMoreOptionsMenu() playerOptionsViewModel.showSleepTimerDialog() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.player_track_details)) }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxInformation, - contentDescription = null, - ) - }, - enabled = currentTrack != null, - onClick = { + } + + trackDetailsLabel -> { playerOptionsViewModel.dismissMoreOptionsMenu() playerOptionsViewModel.showTrackDetails() - }, - ) - } - } + } + } + }, + side = AnchorSide.Top, + anchor = { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + playerOptionsViewModel.showMoreOptionsMenu() + onMoreOptions() + } + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options" + ) + } + }) } Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { scope.launch { if (playerUiState.volume <= 0f) { @@ -481,12 +474,12 @@ fun AppLargePlayer( playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow else -> Iconsax.IconsaxVolumeHigh } - Icon( + MillenniumIcon( imageVector = volumeIcon, contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute" ) } - Slider( + MillenniumSlider( value = playerUiState.volume, onValueChange = { lastNonZeroVolume = it @@ -494,6 +487,7 @@ fun AppLargePlayer( audioPlayer.setVolume(it) } }, + label = "Volume", modifier = Modifier.fillMaxWidth(0.62f) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt index 33b03057..c05c604c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt @@ -43,8 +43,7 @@ internal data class PlayerUiState( val playerState: PlayerState = PlayerState.IDLE, val loopState: LoopState = LoopState.NONE, val isShuffling: Boolean = false, - val volume: Float = 0f, -) { + val volume: Float = 0f) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val displayDuration: Duration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration @@ -74,8 +73,7 @@ internal data class PlayerUiState( @Composable internal fun rememberPlayerUiState( audioPlayer: AudioPlayerInterface, - audioPlayerQueue: AudioPlayerQueue, -): PlayerUiState { + audioPlayerQueue: AudioPlayerQueue): PlayerUiState { val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null) val playerState by audioPlayer.playerStateFlow.collectAsState() @@ -99,8 +97,7 @@ internal fun rememberPlayerUiState( playerState = playerState, loopState = loopState, isShuffling = isShuffling, - volume = volume, - ) + volume = volume) // val logger = rememberLogger() // logger.d { state.toDebugString() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index 6e79cfa3..9beb0e79 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -37,17 +37,18 @@ import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetValue -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import com.composeunstyled.Sheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.UnstyledBottomSheet +import com.composeunstyled.rememberBottomSheetState +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -74,8 +75,6 @@ import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.baseSurfaceBackdrop import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent @@ -98,12 +97,10 @@ fun AppShell( viewModel: AppShellViewModel = koinViewModel(), queueViewModel: PlayerQueueContentViewModel = koinViewModel(), alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel(), - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val navigatorCommands: NavigationCommands = koinInject() val remotePlaybackController: RemotePlaybackController = koinInject() - val baseUIColors = LocalBaseUIColors.current - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostState = remember { MillenniumSnackbarHostState() } LaunchedEffect(remotePlaybackController) { remotePlaybackController.events.collect { message -> snackbarHostState.showSnackbar(message) @@ -155,88 +152,73 @@ fun AppShell( Box( modifier = Modifier .fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxSize() - .baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight) - .hazeSource(state = bgHazeState) // <-- Put it here! - ) - Row(modifier = Modifier.fillMaxSize()) { + .background(MillenniumTheme.colors.background) + .hazeSource(state = bgHazeState) // <-- Put it here! + ) + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.weight(1f)) { AppSidebar( hazeState = bgHazeState, navigator = navigator, navigationState = navigationState ) - VerticalDivider( + MillenniumSeparator( modifier = Modifier.fillMaxHeight(), - color = Color.Gray.copy(alpha = 0.2f), - thickness = 1.dp, - ) - Box(modifier = Modifier.weight(1f).hazeSource(hazeState)) { + vertical = true) + Box(modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { content() } } - - if (isQueueVisible) { - Box( - modifier = Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { queueViewModel.setQueueVisibility(false) }, - ) - ) - } - - if (isAlternativeVisible) { - Box( - modifier = Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { alternativeViewModel.setAlternativeVisibility(false) }, - ) - ) - } - - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { queueViewModel.setQueueVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { - PlayerQueueContent( - viewModel = queueViewModel, - modifier = Modifier.fillMaxSize(), - ) - } - - AlternativeTrackSheet( - isVisible = isAlternativeVisible, - onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { - AlternativeTrackContent( - viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize(), - ) - } + AppLargePlayer( + hazeState = hazeState, + modifier = Modifier.fillMaxWidth(), + onQueue = queueViewModel::toggleQueueVisibility, + onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, + onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }) + } + + if (isQueueVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { queueViewModel.setQueueVisibility(false) }) + ) + } + + if (isAlternativeVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { alternativeViewModel.setAlternativeVisibility(false) }) + ) + } + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { queueViewModel.setQueueVisibility(false) }, + modifier = Modifier.fillMaxSize()) { + PlayerQueueContent( + viewModel = queueViewModel, + modifier = Modifier.fillMaxSize()) + } + + AlternativeTrackSheet( + isVisible = isAlternativeVisible, + onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, + modifier = Modifier.fillMaxSize()) { + AlternativeTrackContent( + viewModel = alternativeViewModel, + modifier = Modifier.fillMaxSize()) } - AppLargePlayer( - hazeState = hazeState, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onQueue = queueViewModel::toggleQueueVisibility, - onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, - onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }, - ) } } else { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() - .baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight), - ) + .background(MillenniumTheme.colors.background)) CompactPlayerOverlay( navigator = navigator, navigationState = navigationState, @@ -244,8 +226,7 @@ fun AppShell( onQueue = queueViewModel::toggleQueueVisibility, onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, onExpandLyrics = viewModel::showLyricsOverlay, - content = content, - ) + content = content) QueueSheet( isVisible = isQueueVisible, @@ -254,19 +235,16 @@ fun AppShell( ) { PlayerQueueContent( viewModel = queueViewModel, - modifier = Modifier.fillMaxSize(), - ) + modifier = Modifier.fillMaxSize()) } AlternativeTrackSheet( isVisible = isAlternativeVisible, onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { + modifier = Modifier.fillMaxSize()) { AlternativeTrackContent( viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize(), - ) + modifier = Modifier.fillMaxSize()) } if (isLyricsOverlayVisible) { @@ -275,14 +253,11 @@ fun AppShell( properties = DialogProperties( usePlatformDefaultWidth = false, dismissOnBackPress = true, - dismissOnClickOutside = true, - ), - ) { - Box(modifier = Modifier.fillMaxSize()) { + dismissOnClickOutside = true)) { + Box(modifier = Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { LyricsScreen( modifier = Modifier.fillMaxSize(), - onClose = { viewModel.hideLyricsOverlay() }, - ) + onClose = { viewModel.hideLyricsOverlay() }) } } } @@ -292,19 +267,16 @@ fun AppShell( // Drawn last so it floats above the players/sheets, just above the // bottom overlay (large player or compact player + bottombar). - SnackbarHost( + MillenniumSnackbarHost( hostState = snackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = bottomOverlayInset + 12.dp), - ) + .padding(bottom = bottomOverlayInset + 12.dp)) } } @OptIn( - ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class, - ExperimentalMaterial3Api::class -) + ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable private fun BoxWithConstraintsScope.CompactPlayerOverlay( navigator: Navigator, @@ -313,8 +285,7 @@ private fun BoxWithConstraintsScope.CompactPlayerOverlay( onQueue: () -> Unit, onAlternativeSource: () -> Unit, onExpandLyrics: () -> Unit, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val density = LocalDensity.current val scope = rememberCoroutineScope() @@ -324,88 +295,60 @@ private fun BoxWithConstraintsScope.CompactPlayerOverlay( val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx val peekHeight = with(density) { peekHeightPx.toDp() } - val sheetHeightPx = with(density) { maxHeight.toPx() } - - val sheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded, - skipHiddenState = true, - ) - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = sheetState, - ) - - val progressState = remember { mutableFloatStateOf(0f) } - LaunchedEffect(sheetState, sheetHeightPx, peekHeightPx) { - snapshotFlow { - try { - sheetState.requireOffset() - } catch (_: IllegalStateException) { - peekHeightPx - } - }.collect { offset -> - val expandedOffset = 0f - val collapsedOffset = sheetHeightPx - peekHeightPx - val range = (collapsedOffset - expandedOffset).coerceAtLeast(1f) - val rawProgress = (collapsedOffset - offset) / range - progressState.floatValue = rawProgress.coerceIn(0f, 1f) - } + val partialDetent = remember(peekHeight) { + SheetDetent("player-peek") { _, _ -> peekHeight } } + val sheetState = rememberBottomSheetState( + initialDetent = partialDetent, + detents = listOf(partialDetent, SheetDetent.FullyExpanded)) - val uiState by remember { - derivedStateOf { - viewModel.compactSheetUiState(progressState.floatValue) - } - } + val progress = runCatching { + sheetState.progress(partialDetent, SheetDetent.FullyExpanded) + }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f + val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f)) - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = peekHeight, - sheetDragHandle = null, - sheetContainerColor = Color.Transparent, - sheetShape = RectangleShape, - sheetTonalElevation = 0.dp, - sheetShadowElevation = 0.dp, - sheetContent = { - Box(modifier = Modifier.fillMaxSize()) { - if (uiState.showExpandedPlayer) { - AppExpandedPlayer( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, - onCollapse = { - scope.launch { sheetState.partialExpand() } - }, - onQueue = onQueue, - onAlternativeSource = onAlternativeSource, - onExpandLyrics = onExpandLyrics, - ) - } + Box(modifier = Modifier.fillMaxSize()) { + content() - if (uiState.floatingPlayerAlpha > 0.01f) { - Column(modifier = Modifier.fillMaxWidth()) { - AppFloatingPlayer( + UnstyledBottomSheet( + state = sheetState, + modifier = Modifier.fillMaxSize()) { + Sheet(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + if (uiState.showExpandedPlayer) { + AppExpandedPlayer( modifier = Modifier - .offset(y = 12.dp) - .graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }, - ) + .fillMaxSize() + .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, + onCollapse = { + scope.launch { sheetState.animateTo(partialDetent) } + }, + onQueue = onQueue, + onAlternativeSource = onAlternativeSource, + onExpandLyrics = onExpandLyrics) + } + + if (uiState.floatingPlayerAlpha > 0.01f) { + Column(modifier = Modifier.fillMaxWidth()) { + AppFloatingPlayer( + modifier = Modifier + .offset(y = 12.dp) + .graphicsLayer { + alpha = uiState.floatingPlayerAlpha + }) + } } } } - }, - ) { _ -> - Box(modifier = Modifier.fillMaxSize()) { - content() + } + + if (uiState.floatingPlayerAlpha > 0.01f) { + AppBottombar( + navigator = navigator, + navigationState = navigationState, + modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer { + alpha = uiState.floatingPlayerAlpha + }) } } - if (uiState.floatingPlayerAlpha > 0.01f) { - AppBottombar( - navigator = navigator, - navigationState = navigationState, - modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }, - ) - } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt index ed3bffcc..4139fe18 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt @@ -34,12 +34,10 @@ data class CompactSheetUiState( val showExpandedPlayer: Boolean, val expandedPlayerAlpha: Float, val floatingPlayerAlpha: Float, - val bottomBarAlpha: Float, -) + val bottomBarAlpha: Float) data class LyricsOverlayState( - val isVisible: Boolean = false, -) + val isVisible: Boolean = false) class AppShellViewModel : ViewModel() { val sidebarMinWidth = 840.dp @@ -91,8 +89,7 @@ class AppShellViewModel : ViewModel() { showExpandedPlayer = progress > expandedPlayerVisibilityThreshold, expandedPlayerAlpha = progress, floatingPlayerAlpha = floatingPlayerAlpha(progress), - bottomBarAlpha = 1f - progress, - ) + bottomBarAlpha = 1f - progress) } fun showLyricsOverlay() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index e5dfbbf8..e63f2507 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -18,30 +18,19 @@ package dev.krtirtho.spotube.modules.shell import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -49,27 +38,30 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.composeunstyled.Text import dev.chrisbanes.haze.HazeState import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.SurfaceRole -import dev.krtirtho.spotube.core.ui.base.baseSurfaceMaterial -import dev.krtirtho.spotube.core.ui.component.SidebarItem -import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel -import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar -import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken import dev.krtirtho.spotube.sidebarTabs import org.jetbrains.compose.resources.Font import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumSidebar +import org.spotube.millennium.composables.MillenniumSidebarContent +import org.spotube.millennium.composables.MillenniumSidebarFooter +import org.spotube.millennium.composables.MillenniumSidebarGroup +import org.spotube.millennium.composables.MillenniumSidebarGroupContent +import org.spotube.millennium.composables.MillenniumSidebarGroupLabel +import org.spotube.millennium.composables.MillenniumSidebarHeader +import org.spotube.millennium.composables.MillenniumSidebarItem +import org.spotube.millennium.composables.MillenniumSidebarMenu +import org.spotube.millennium.composables.MillenniumSidebarTrigger import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.cookie_regular @@ -82,121 +74,94 @@ fun AppSidebar( libraryState: LibraryState = koinInject() ) { var expanded by rememberSaveable { mutableStateOf(true) } - val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) + val groupExpansion = remember { mutableStateMapOf() } val currentLibraryTab by libraryState.currentTab.collectAsState() - val listState = rememberLazyListState() - - Column( - modifier = modifier - .fillMaxHeight() - .width(width) - .baseSurfaceMaterial(role = SurfaceRole.PrimaryBackground), - horizontalAlignment = Alignment.CenterHorizontally + MillenniumSidebar( + modifier = modifier, + collapsed = !expanded, + onCollapsedChange = { expanded = !it }, ) { - Column(modifier = Modifier.weight(1f)) { + MillenniumSidebarHeader { Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { Text( text = "Spotube", - style = MaterialTheme.typography.headlineLarge.copy( + style = AppTheme.typography.headlineLarge.copy( fontFamily = FontFamily( - listOf( - Font(Res.font.cookie_regular, weight = FontWeight.Normal) - ) + listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal)) ) ), - color = MaterialTheme.colorScheme.onSurface - ) - } - GhostIconButton( - onClick = { expanded = !expanded } - ) { - Icon( - imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken, - contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + color = AppTheme.colors.onSurface ) } + MillenniumSidebarTrigger() } - - Spacer(modifier = Modifier.height(8.dp)) - - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn(state = listState) { - items(sidebarTabs.size) { index -> - when (val tab = sidebarTabs[index]) { - is NavigationItem.Group<*> -> { - SidebarSectionLabel(text = tab.title, expanded = expanded) - tab.items.forEach { - val selected = navigationState.topLevelRoute == it.route - SidebarItem( - label = it.title, - icon = it.icon, - onClick = { - if (it.route == Routes.Library) { - libraryState.currentTab.value = - it.data as LibraryTab - } - navigator.navigate(it.route) - }, - selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected, - expanded = expanded, - badge = if (it.route == Routes.Library - && it.data == LibraryTab.Downloads - ) { - { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } else { - null - }, - ) + } + MillenniumSidebarContent { + sidebarTabs.forEach { entry -> + when (entry) { + is NavigationItem.Group<*> -> { + val isGroupExpanded = groupExpansion[entry.title] ?: true + MillenniumSidebarGroup( + expanded = isGroupExpanded, + collapsible = true, + onExpandedChange = { groupExpansion[entry.title] = it }) { + MillenniumSidebarGroupLabel(entry.title) + MillenniumSidebarGroupContent { + MillenniumSidebarMenu { + entry.items.forEach { item -> + val selected = navigationState.topLevelRoute == item.route && + (item.route != Routes.Library || currentLibraryTab == item.data) + SidebarNavItem( + item = item, + selected = selected, + onClick = { + if (item.route == Routes.Library) { + (item.data as? LibraryTab)?.let { + libraryState.currentTab.value = it + } + } + navigator.navigate(item.route) + }) + } } - - } - - is NavigationItem.Tab<*> -> { - val selected = navigationState.topLevelRoute == tab.route - SidebarItem( - label = tab.title, - icon = tab.icon, - onClick = { - navigator.navigate(tab.route) - }, - selected = selected, - expanded = expanded, - ) } } } + + is NavigationItem.Tab<*> -> { + SidebarNavItem( + item = entry, + selected = navigationState.topLevelRoute == entry.route, + onClick = { navigator.navigate(entry.route) }) + } } - VerticalScrollbar( - listState = listState, - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - ) } } - HorizontalDivider() - SidebarItem( - label = "Settings", - icon = Iconsax.IconsaxSetting2, - onClick = { - navigator.navigate(Routes.Settings) - }, - selected = navigationState.topLevelRoute == Routes.Settings, - expanded = expanded, - ) - Spacer(modifier = Modifier.height(120.dp)) + MillenniumSidebarFooter { + MillenniumSidebarItem( + label = "Settings", + onClick = { navigator.navigate(Routes.Settings) }, + selected = navigationState.topLevelRoute == Routes.Settings, + icon = Iconsax.IconsaxSetting2) + } } } + +@Composable +private fun SidebarNavItem( + item: NavigationItem.Tab<*>, + selected: Boolean, + onClick: () -> Unit) { + MillenniumSidebarItem( + label = item.title, + onClick = onClick, + selected = selected, + contentDescription = item.title, + icon = item.icon) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt index 1356c83a..ea66bb29 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt @@ -17,18 +17,19 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.modules.saved_tracks.SavedState import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks @@ -40,8 +41,7 @@ import kotlinx.coroutines.launch @Composable fun PlayerHeartButton( audioPlayerQueue: AudioPlayerQueue, - savedTracksViewModel: SavedTracksViewModel, -) { + savedTracksViewModel: SavedTracksViewModel) { val scope = rememberCoroutineScope() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -68,15 +68,13 @@ fun PlayerHeartButton( } } } - IconButton( + MillenniumIconButton( onClick = ::onLike, enabled = isSavedTrackState is SavedState.Success, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape), - ) { - Icon( + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, contentDescription = if (isLiked) "Unlike" else "Like", - tint = if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = if (isLiked) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt index 706fb061..99fd900f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt @@ -17,6 +17,17 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,13 +35,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,9 +50,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive @@ -59,15 +66,13 @@ fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) { onMinutesChange = viewModel::updateSleepTimerMinutes, onStart = viewModel::startSleepTimer, onCancelTimer = viewModel::cancelSleepTimer, - onDismiss = viewModel::dismissSleepTimerDialog, - ) + onDismiss = viewModel::dismissSleepTimerDialog) } uiState.trackDetails?.let { details -> TrackDetailsDialog( details = details, - onDismiss = viewModel::dismissTrackDetails, - ) + onDismiss = viewModel::dismissTrackDetails) } } @@ -78,48 +83,47 @@ private fun SleepTimerDialog( onMinutesChange: (String) -> Unit, onStart: () -> Unit, onCancelTimer: () -> Unit, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text("Sleep timer", style = MaterialTheme.typography.titleLarge) - }, - actions = { - if (remainingMs != null) { - SecondaryButton(onClick = onCancelTimer) { Text("Cancel timer") } - } - SecondaryButton(onClick = onDismiss) { Text("Close") } - PrimaryButton( - onClick = onStart, - enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true, - ) { - Text("Start") - } - }, - ) { + title = "Sleep timer") { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (remainingMs != null) { Text( text = "Playback will pause in ${formatRemainingTime(remainingMs)}.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) } else { Text( text = "Pause playback after the selected number of minutes.", - style = MaterialTheme.typography.bodyMedium, - ) + style = AppTheme.typography.bodyMedium) + } + val minutesState = rememberTextFieldState(minutes) + LaunchedEffect(minutesState.text) { + onMinutesChange(minutesState.text.toString()) + } + MillenniumTextField( + state = minutesState, + label = "Minutes", + placeholder = "Choose between 1 and 600 minutes", + modifier = Modifier.fillMaxWidth()) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (remainingMs != null) { + MillenniumButton( + onClick = onCancelTimer, + style = MillenniumButtonStyle.Secondary) { Text("Cancel timer") } + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Close") } + MillenniumButton( + onClick = onStart, + enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) { + Text("Start") + } } - OutlinedTextField( - value = minutes, - onValueChange = onMinutesChange, - modifier = Modifier.fillMaxWidth(), - label = { Text("Minutes") }, - supportingText = { Text("Choose between 1 and 600 minutes") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - ) } } } @@ -127,31 +131,22 @@ private fun SleepTimerDialog( @Composable private fun TrackDetailsDialog( details: PlayerTrackDetails, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Track details", style = MaterialTheme.typography.titleLarge) - Text( - details.track.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - actions = { - SecondaryButton(onClick = onDismiss) { Text("Close") } - }, - ) { + title = "Track details") { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + details.track.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) if (details.isLoading) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() + horizontalArrangement = Arrangement.Center) { + MillenniumIndeterminateProgress( + modifier = Modifier.fillMaxWidth()) } } @@ -159,17 +154,17 @@ private fun TrackDetailsDialog( LocalCacheBanner(details.localCacheEntry) } if (details.localCacheEntry == null && details.activeStreamMatchesPreference == false) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.tertiaryContainer), + shape = RoundedCornerShape(12.dp))) { Text( "The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request.", modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onTertiaryContainer) } } @@ -177,12 +172,11 @@ private fun TrackDetailsDialog( Text( "Matched audio source", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) val source = details.matchedSource if (details.isLoading) { - Text("Loading matched source…", style = MaterialTheme.typography.bodySmall) + Text("Loading matched source…", style = AppTheme.typography.bodySmall) } else if (source == null) { Text( details.error ?: if (details.localCacheEntry != null) { @@ -190,9 +184,8 @@ private fun TrackDetailsDialog( } else { "No matched source is available." }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } else { DetailRow("Title", source.title) DetailRow("ID", source.id) @@ -204,78 +197,70 @@ private fun TrackDetailsDialog( Text( "Available streams", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) when { details.localCacheEntry != null && details.streams.isEmpty() -> Text( "This track is being served from the local cache. Stream variants are not used for this playback.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) - details.isLoading -> Text("Loading streams…", style = MaterialTheme.typography.bodySmall) + details.isLoading -> Text("Loading streams…", style = AppTheme.typography.bodySmall) details.streams.isEmpty() -> Text( details.error ?: "No streams are available for this source.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) else -> details.streams.forEach { stream -> val isSelected = stream == details.preferredStream - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - }, - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor( + if (isSelected) { + AppTheme.colors.primaryContainer + } else { + AppTheme.colors.surfaceContainerHigh + }), + shape = RoundedCornerShape(12.dp))) { Column( modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = streamFormatAndQuality(stream), - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer + AppTheme.colors.onPrimaryContainer } else { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface }, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) if (isSelected) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxCheckCircle, contentDescription = "Currently selected stream", - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } } Text( "${stream.protocol} • ${stream.codec} • ${stream.container}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) Text( stream.url, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) if (isSelected) { Text( "Matches your streaming format & quality preference", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } } } @@ -285,39 +270,40 @@ private fun TrackDetailsDialog( details.activeStreamUrl?.let { activeUrl -> DetailRow("Active playback URL", activeUrl) } + + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Close") } } } } @Composable private fun LocalCacheBanner(entry: CacheEntry) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.secondaryContainer), + shape = RoundedCornerShape(12.dp))) { Row( modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( imageVector = Iconsax.IconsaxDirectboxReceive, contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) + tint = AppTheme.colors.onSecondaryContainer) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( "Served from local cache", - style = MaterialTheme.typography.labelLarge, + style = AppTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) + color = AppTheme.colors.onSecondaryContainer) Text( "${entry.filename} • ${formatFileSize(entry.sizeBytes)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer) } } } @@ -327,9 +313,8 @@ private fun LocalCacheBanner(entry: CacheEntry) { private fun TrackMetadataSection(track: MetadataTrack) { Text( "Metadata track", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) DetailRow("ID", track.id) DetailRow("Title", track.title) DetailRow("Artists", track.artists.joinToString { it.name }) @@ -354,19 +339,16 @@ private fun DetailRow(label: String, value: String?) { if (value.isNullOrBlank()) return Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( label, modifier = Modifier.weight(0.38f), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) Text( value, modifier = Modifier.weight(0.62f), - style = MaterialTheme.typography.bodySmall, - ) + style = AppTheme.typography.bodySmall) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt index 4b28e100..c811248b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -58,8 +58,7 @@ data class PlayerTrackDetails( val activeStreamMatchesPreference: Boolean? = null, val localCacheEntry: CacheEntry? = null, val isLoading: Boolean = false, - val error: String? = null, -) + val error: String? = null) data class PlayerOptionsUiState( val isMoreOptionsMenuOpen: Boolean = false, @@ -67,8 +66,7 @@ data class PlayerOptionsUiState( val isSleepTimerDialogOpen: Boolean = false, val sleepTimerMinutes: String = "30", val sleepTimerRemainingMs: Long? = null, - val trackDetails: PlayerTrackDetails? = null, -) + val trackDetails: PlayerTrackDetails? = null) class PlayerOptionsViewModel( private val audioPlayer: AudioPlayerInterface, @@ -77,8 +75,7 @@ class PlayerOptionsViewModel( private val streamingUrlRepository: StreamingUrlRepository, private val cacheManager: CacheManager, private val settingsRepository: SettingsRepository, - private val pluginManager: AudioPluginSource, -) : ViewModel() { + private val pluginManager: AudioPluginSource) : ViewModel() { private val _uiState = MutableStateFlow(PlayerOptionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -150,8 +147,7 @@ class PlayerOptionsViewModel( trackDetailsJob?.cancel() _uiState.update { it.copy( - trackDetails = PlayerTrackDetails(track = track, isLoading = true), - ) + trackDetails = PlayerTrackDetails(track = track, isLoading = true)) } trackDetailsJob = viewModelScope.launch { runCatching { @@ -174,15 +170,13 @@ class PlayerOptionsViewModel( val preferredStream = selectPreferredAudioStream( streams = streams, preferredFormat = settings.streamingMusicFormat, - preferredQuality = settings.streamingMusicQuality, - ) + preferredQuality = settings.streamingMusicQuality) val activeStreamUrl = if (cachedStreamSource != null) cachedStream.url else null val activeStreamMatchesPreference = when { activeStreamUrl == null || preferredStream == null -> null else -> activeStreamUrl == normalizeManifestUrl( url = preferredStream.url, - protocol = preferredStream.protocol, - ) + protocol = preferredStream.protocol) } PlayerTrackDetails( @@ -199,8 +193,7 @@ class PlayerOptionsViewModel( streams.isEmpty() && localCacheEntry == null -> "Stream details are not cached yet. Play the track first to load them." else -> null - }, - ) + }) } }.onSuccess { details -> _uiState.update { current -> @@ -216,9 +209,7 @@ class PlayerOptionsViewModel( trackDetails = PlayerTrackDetails( track = track, isLoading = false, - error = error.message ?: "Failed to load track details.", - ), - ) + error = error.message ?: "Failed to load track details.")) } else current } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt index 97e0aa28..662ea8ed 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.modules.shell.alternative_track + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,11 +36,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -56,33 +59,29 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun AlternativeTrackContent( viewModel: AlternativeTrackContentViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val uiState by viewModel.alternativeTrackUiState.collectAsState() LaunchedEffect(uiState.currentTrack?.id) { viewModel.loadAlternatives() } - Surface(modifier = modifier) { + Box(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = "Alternative Sources", - style = MaterialTheme.typography.titleLarge, - ) + style = AppTheme.typography.titleLarge) val currentTrack = uiState.currentTrack if (currentTrack == null) { Text( text = "No track currently playing", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) return@Column } @@ -90,18 +89,14 @@ fun AlternativeTrackContent( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + verticalAlignment = Alignment.CenterVertically) { + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(12.dp)) Text( text = "Searching for alternative sources...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } return@Column } @@ -109,25 +104,21 @@ fun AlternativeTrackContent( if (uiState.alternatives.isEmpty()) { Text( text = "No alternative sources found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) return@Column } LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + verticalArrangement = Arrangement.spacedBy(6.dp)) { items( items = uiState.alternatives, - key = { source -> source.id }, - ) { source -> + key = { source -> source.id }) { source -> AlternativeSourceRow( source = source, isActive = source.id == uiState.activeSourceId, - onClick = { viewModel.selectAlternative(source) }, - ) + onClick = { viewModel.selectAlternative(source) }) } } } @@ -138,67 +129,47 @@ fun AlternativeTrackContent( private fun AlternativeSourceRow( source: AudioSource, isActive: Boolean, - onClick: () -> Unit, -) { + onClick: () -> Unit) { val thumbnailUrl = source.thumbnails.maxByOrNull { it.width * it.height }?.url val rowColor = if (isActive) { - MaterialTheme.colorScheme.secondaryContainer + AppTheme.colors.secondaryContainer } else { - MaterialTheme.colorScheme.surfaceContainerHigh + AppTheme.colors.surfaceContainerHigh } - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = onClick), - tonalElevation = if (isActive) 2.dp else 0.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(64.dp) - .background(rowColor) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + MillenniumListTile( + onClick = onClick, + selected = isActive, + leading = { Box( modifier = Modifier .size(44.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { if (thumbnailUrl != null) { AsyncImage( model = thumbnailUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = source.title.firstOrNull()?.uppercase() ?: "?", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = source.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isActive) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) + }, + title = { + Text( + text = source.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + }, + subtitle = { + Column { val subtitle = buildString { source.artist?.let { append(it) } if (source.album != null) { @@ -209,27 +180,24 @@ private fun AlternativeSourceRow( if (subtitle.isNotEmpty()) { Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } Text( text = "Confidence: ${(source.confidence * 100).toInt()}%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } - + }, + trailing = { if (isActive) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckCircle, contentDescription = "Active source", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) + tint = AppTheme.colors.primary, + modifier = Modifier.size(22.dp)) } - } - } + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt index 0e9bd5b6..5ccd3101 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt @@ -37,13 +37,11 @@ data class AlternativeTrackUiState( val currentTrack: MetadataTrack? = null, val alternatives: List = emptyList(), val activeSourceId: String? = null, - val isLoading: Boolean = false, -) + val isLoading: Boolean = false) class AlternativeTrackContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, - private val alternativeTracksRepository: AlternativeTracksRepository, -) : ViewModel() { + private val alternativeTracksRepository: AlternativeTracksRepository) : ViewModel() { private val alternativeVisibilityFlow = MutableStateFlow(false) private val alternativesFlow = MutableStateFlow>(emptyList()) private val activeSourceIdFlow = MutableStateFlow(null) @@ -55,20 +53,17 @@ class AlternativeTrackContentViewModel( audioPlayerQueue.currentQueueEntryFlow, alternativesFlow, activeSourceIdFlow, - isLoadingFlow, - ) { currentEntry, alternatives, activeSourceId, isLoading -> + isLoadingFlow) { currentEntry, alternatives, activeSourceId, isLoading -> val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track AlternativeTrackUiState( currentTrack = currentTrack, alternatives = alternatives, activeSourceId = activeSourceId, - isLoading = isLoading, - ) + isLoading = isLoading) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = AlternativeTrackUiState(), - ) + initialValue = AlternativeTrackUiState()) fun toggleAlternativeVisibility() { alternativeVisibilityFlow.update { !it } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt index 220f0869..d949602b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt @@ -17,27 +17,12 @@ package dev.krtirtho.spotube.modules.shell.alternative_track -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.core.animateDp -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.modules.shell.player_queue.BottomSheetContent +import dev.krtirtho.spotube.modules.shell.player_queue.EndDrawerSheet private val SlidingSheetBreakpoint = 840.dp @@ -46,61 +31,12 @@ fun AlternativeTrackSheet( isVisible: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { BoxWithConstraints(modifier = modifier) { if (maxWidth >= SlidingSheetBreakpoint) { - SlidingAlternativeSheet(isVisible, onDismiss, content) + EndDrawerSheet(isVisible, onDismiss, content) } else { - BottomAlternativeSheet(isVisible, onDismiss, content) - } - } -} - -@Composable -private fun SlidingAlternativeSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = isVisible, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), - enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), - exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), - ) { - val animatedShadowElevation = transition.animateDp(label = "alternativeShadow") { state -> - if (state == EnterExitState.Visible) 20.dp else 0.dp - } - Surface( - modifier = Modifier - .fillMaxHeight() - .width(460.dp), - shape = MaterialTheme.shapes.large, - tonalElevation = 2.dp, - shadowElevation = animatedShadowElevation.value, - ) { - content() - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomAlternativeSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - ) { - content() + BottomSheetContent(isVisible, onDismiss, content) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 0042df8d..33d9a4d2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -17,6 +17,17 @@ package dev.krtirtho.spotube.modules.shell.player_queue + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -37,13 +48,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -58,13 +66,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.jam.JamParticipant -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ListRowTile -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore @@ -81,8 +82,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun PlayerQueueContent( viewModel: PlayerQueueContentViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val state by viewModel.queueContentUiState.collectAsState() val displayItems = state.displayItems val filterQuery = state.filterQuery @@ -96,45 +96,35 @@ fun PlayerQueueContent( onMove = { from, to -> if (isFiltered || isReadOnly) return@rememberReorderableLazyListState viewModel.onMove(from.index, to.index) - }, - ) + }) - Surface(modifier = modifier) { + Box(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = "Queue", - style = MaterialTheme.typography.titleLarge, - ) + style = AppTheme.typography.titleLarge) Row( horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = filterQuery, - onValueChange = viewModel::setQueueFilter, - placeholder = { Text("Filter queue...") }, - leadingIcon = { - Icon( - Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - modifier = Modifier.weight(1f), - ) + verticalAlignment = Alignment.CenterVertically) { + val filterState = rememberTextFieldState(filterQuery) + LaunchedEffect(filterState.text) { + viewModel.setQueueFilter(filterState.text.toString()) + } + MillenniumTextField( + state = filterState, + label = "", + placeholder = "Filter queue...", + modifier = Modifier.weight(1f)) if (!isReadOnly) { - IconButton( + MillenniumIconButton( onClick = viewModel::clearQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), - ) { - Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") } } } @@ -142,16 +132,14 @@ fun PlayerQueueContent( if (displayItems.isEmpty()) { Text( text = "No queue entries", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } else { - Card { + MillenniumCard { LazyColumn( modifier = Modifier.fillMaxSize(), state = lazyListState, - contentPadding = PaddingValues(bottom = 8.dp), - ) { + contentPadding = PaddingValues(bottom = 8.dp)) { items(displayItems, key = { it.id }) { item -> ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) @@ -164,8 +152,7 @@ fun PlayerQueueContent( onDragStopped = { viewModel.onDragStop() }, showOptions = !isReadOnly, enabled = !isReadOnly, - onParticipantClick = { selectedParticipant = it }, - ) + onParticipantClick = { selectedParticipant = it }) } } } @@ -179,8 +166,7 @@ fun PlayerQueueContent( onDismiss = { selectedParticipant = null }, onKick = { viewModel.kickParticipant(participant.id) }, onBan = { viewModel.banParticipant(participant.id) }, - onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }, - ) + onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }) } } } @@ -193,83 +179,70 @@ private fun ParticipantDialog( onDismiss: () -> Unit, onKick: () -> Unit, onBan: () -> Unit, - onRemoveSuggestions: () -> Unit, -) { + onRemoveSuggestions: () -> Unit) { AdaptiveDialogBottomSheet( onDismiss = onDismiss, - title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) }, - ) { + title = { Text(participant.displayName, style = AppTheme.typography.titleLarge) }) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.padding(vertical = 8.dp), - ) { + modifier = Modifier.padding(vertical = 8.dp)) { ParticipantAvatar(participant, size = 40) Text( text = participant.displayName, - style = MaterialTheme.typography.bodyLarge, - ) + style = AppTheme.typography.bodyLarge) if (participant.isHost) { Text( text = "Host", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } } if (isJamHost && !participant.isHost) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - ListRowTile( + MillenniumSeparator( + modifier = Modifier.fillMaxWidth()) + MillenniumListTile( onClick = { onKick() onDismiss() }, leading = { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) }, title = { Text("Kick") }, - subtitle = { Text("Remove them from the session") }, - ) - ListRowTile( + subtitle = { Text("Remove them from the session") }) + MillenniumListTile( onClick = { onBan() onDismiss() }, leading = { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) }, title = { Text("Ban") }, - subtitle = { Text("Kick and prevent them from rejoining") }, - ) - ListRowTile( + subtitle = { Text("Kick and prevent them from rejoining") }) + MillenniumListTile( onClick = { onRemoveSuggestions() onDismiss() }, leading = { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxMusicSquareRemove, - contentDescription = null, - ) + contentDescription = null) }, title = { Text("Remove suggestions") }, - subtitle = { Text("Remove every track they added to the queue") }, - ) + subtitle = { Text("Remove every track they added to the queue") }) } } } @@ -281,14 +254,12 @@ private fun ParticipantAvatar(participant: JamParticipant, size: Int) { modifier = Modifier .size(size.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { + .background(AppTheme.colors.primaryContainer), + contentAlignment = Alignment.Center) { Text( text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onPrimaryContainer) } } @@ -302,11 +273,10 @@ private fun QueueItemRow( onDragStopped: () -> Unit, showOptions: Boolean = true, enabled: Boolean = true, - onParticipantClick: (JamParticipant) -> Unit = {}, -) { + onParticipantClick: (JamParticipant) -> Unit = {}) { var showMenu by remember { mutableStateOf(false) } - ListRowTile( + MillenniumListTile( onClick = onPlayClick, enabled = enabled, selected = item.isCurrent, @@ -315,9 +285,8 @@ private fun QueueItemRow( Row( modifier = Modifier .height(72.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( Iconsax.IconsaxDragHandle, contentDescription = if (reorderScope != null) "Reorder" else null, modifier = Modifier @@ -327,36 +296,30 @@ private fun QueueItemRow( with(reorderScope) { Modifier.draggableHandle( onDragStarted = { onDragStarted() }, - onDragStopped = onDragStopped, - ) + onDragStopped = onDragStopped) } } else { Modifier - }, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + }), + tint = AppTheme.colors.onSurfaceVariant) Box( modifier = Modifier .size(48.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { if (item.imageUrl != null) { AsyncImage( model = item.imageUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = "${item.originalIndex + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } } @@ -364,31 +327,28 @@ private fun QueueItemRow( title = { Text( text = item.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (item.isCurrent) { - MaterialTheme.colorScheme.onSecondaryContainer + AppTheme.colors.onSecondaryContainer } else { - MaterialTheme.colorScheme.onSurface - }, - ) + AppTheme.colors.onSurface + }) }, subtitle = { Text( text = item.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) }, trailing = { Text( text = item.durationLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) Spacer(modifier = Modifier.width(4.dp)) @@ -397,47 +357,35 @@ private fun QueueItemRow( modifier = Modifier .size(28.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) + .background(AppTheme.colors.surfaceVariant) .clickable { onParticipantClick(participant) }, - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } Spacer(modifier = Modifier.width(4.dp)) } if (showOptions) { - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), - ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) - } - } + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + items = listOf("Remove from queue"), + onItemClick = { + onRemoveClick() + showMenu = false + }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options") + } + }) } } ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index a426a839..253c8d83 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -42,8 +42,7 @@ data class QueueItemUi( val imageUrl: String?, val originalIndex: Int, /** Participant who added this item to the jam queue, if any. */ - val addedByParticipant: JamParticipant? = null, -) + val addedByParticipant: JamParticipant? = null) data class QueueContentUiState( val filterQuery: String = "", @@ -52,13 +51,11 @@ data class QueueContentUiState( /** Guests cannot reorder/remove/clear the shared jam queue. */ val isReadOnly: Boolean = false, val isJamHost: Boolean = false, - val participants: List = emptyList(), -) + val participants: List = emptyList()) class PlayerQueueContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, - private val jamRoomService: JamRoomService, -) : ViewModel() { + private val jamRoomService: JamRoomService) : ViewModel() { private val queueVisibilityFlow = MutableStateFlow(false) private val queueFilterFlow = MutableStateFlow("") private val reorderBuffer = MutableStateFlow?>(null) @@ -70,8 +67,7 @@ class PlayerQueueContentViewModel( private val computedItems: StateFlow> = combine( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, - jamRoomService.participants, - ) { queue, currentEntry, participants -> + jamRoomService.participants) { queue, currentEntry, participants -> val currentIndex = if (currentEntry != null) { queue.indexOfFirst { it.matchesCurrent(currentEntry) } } else { @@ -113,22 +109,19 @@ class PlayerQueueContentViewModel( isCurrent = index == currentIndex, imageUrl = imageUrl, originalIndex = index, - addedByParticipant = addedByParticipant, - ) + addedByParticipant = addedByParticipant) } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) + initialValue = emptyList()) val queueContentUiState: StateFlow = combine( computedItems, reorderBuffer, queueFilterFlow, jamRoomService.role, - jamRoomService.participants, - ) { items, buffer, filterQuery, role, participants -> + jamRoomService.participants) { items, buffer, filterQuery, role, participants -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { @@ -145,13 +138,11 @@ class PlayerQueueContentViewModel( isFiltered = isFiltered, isReadOnly = role == JamRole.Guest, isJamHost = role == JamRole.Host, - participants = participants, - ) + participants = participants) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = QueueContentUiState(), - ) + initialValue = QueueContentUiState()) fun toggleQueueVisibility() { queueVisibilityFlow.update { !it } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index 47206bab..7bd75267 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -17,28 +17,19 @@ package dev.krtirtho.spotube.modules.shell.player_queue -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.core.animateDp -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import com.composeunstyled.DrawerPlacement +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.composables.MillenniumDrawer +import org.spotube.millennium.composables.MillenniumDrawerValue +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.rememberMillenniumDrawerState private val SlidingSheetBreakpoint = 840.dp @@ -47,65 +38,54 @@ fun QueueSheet( isVisible: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { BoxWithConstraints(modifier = modifier) { if (maxWidth >= SlidingSheetBreakpoint) { - SlidingQueueSheet(isVisible, onDismiss, content) + EndDrawerSheet(isVisible, onDismiss, content) } else { - BottomQueueSheet(isVisible, onDismiss, content) + BottomSheetContent(isVisible, onDismiss, content) } } } @Composable -private fun SlidingQueueSheet( +internal fun EndDrawerSheet( isVisible: Boolean, onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = isVisible, - modifier = Modifier - .align(Alignment.TopEnd) - .padding( - top = 12.dp, - end = 12.dp, - bottom = 12.dp + LocalAppShellBottomInset.current, - ), - enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), - exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), - ) { - val animatedShadowElevation = transition.animateDp(label = "queueShadow") { state -> - if (state == EnterExitState.Visible) 20.dp else 0.dp - } - Surface( - modifier = Modifier - .fillMaxHeight() - .width(460.dp), - shape = MaterialTheme.shapes.large, - tonalElevation = 2.dp, - shadowElevation = animatedShadowElevation.value, - ) { - content() - } + content: @Composable () -> Unit) { + val drawerState = rememberMillenniumDrawerState() + LaunchedEffect(isVisible) { + drawerState.targetValue = + if (isVisible) MillenniumDrawerValue.Open else MillenniumDrawerValue.Closed + } + LaunchedEffect(drawerState.currentValue) { + if (drawerState.currentValue == MillenniumDrawerValue.Closed && isVisible) { + onDismiss() } } + MillenniumDrawer( + state = drawerState, + placement = DrawerPlacement.End, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) { + content() + } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun BottomQueueSheet( +internal fun BottomSheetContent( isVisible: Boolean, onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - ) { - content() + content: @Composable () -> Unit) { + if (!isVisible) return + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.bottomSheetState.currentDetent) { + if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() } } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + content() + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt index b007002d..6cd60adf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt @@ -17,19 +17,19 @@ package dev.krtirtho.spotube.modules.update +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.mikepenz.markdown.m3.Markdown -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.app_update_dialog_no_notes import spotube.composeapp.generated.resources.app_update_dialog_title @@ -46,35 +46,29 @@ fun AppUpdateDialog( update: AvailableAppUpdate, onDismiss: () -> Unit, onIgnore: () -> Unit, - onUpdate: () -> Unit, -) { - ThemedDialog( + onUpdate: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(Res.string.app_update_dialog_title, update.tag), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - OutlineButton(onClick = onIgnore) { - Text(stringResource(Res.string.update_action_ignore)) - } - PrimaryButton(onClick = onUpdate) { - Text(stringResource(Res.string.update_action_update)) - } - }, - ) { + title = stringResource(Res.string.app_update_dialog_title, update.tag)) { if (update.releaseNotesMarkdown.isNotBlank()) { SelectionContainer { Markdown( content = update.releaseNotesMarkdown, - modifier = Modifier.padding(bottom = 8.dp), - ) + modifier = Modifier.padding(bottom = 8.dp)) } } else { Text(stringResource(Res.string.app_update_dialog_no_notes)) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt index 19c5a8dd..8fff895e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt @@ -43,8 +43,7 @@ import org.koin.core.component.KoinComponent data class AvailableAppUpdate( val tag: String, - val releaseNotesMarkdown: String, -) + val releaseNotesMarkdown: String) /** * Checks GitHub for a newer app release once on startup and exposes it for the UI to @@ -53,8 +52,7 @@ data class AvailableAppUpdate( * process, independent of any screen's lifecycle. */ class AppUpdateService( - private val settingsRepository: SettingsRepository, -) : KoinComponent, AutoCloseable { + private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable { private val logger = Logger.withTag("AppUpdateService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val httpClient = HttpClient(CIO) { @@ -92,8 +90,7 @@ class AppUpdateService( if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) { _availableUpdate.value = AvailableAppUpdate( tag = release.tagName, - releaseNotesMarkdown = release.body.orEmpty(), - ) + releaseNotesMarkdown = release.body.orEmpty()) } } catch (e: Exception) { logger.w(e) { "Failed to check for app updates" } @@ -129,5 +126,4 @@ class AppUpdateService( @Serializable private data class GitHubRelease( @SerialName("tag_name") val tagName: String, - val body: String? = null, -) + val body: String? = null) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt index 97641c6b..0e1e9f50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt @@ -17,19 +17,20 @@ package dev.krtirtho.spotube.modules.update +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.mikepenz.markdown.m3.Markdown -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_update_dialog_current_version import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes @@ -48,36 +49,18 @@ fun PluginUpdateDialog( update: AvailablePluginUpdate, onDismiss: () -> Unit, onIgnore: () -> Unit, - onUpdate: () -> Unit, -) { - ThemedDialog( + onUpdate: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - text = stringResource( - Res.string.plugin_update_dialog_title, - update.pluginName, - update.info.latestVersion, - ), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - OutlineButton(onClick = onIgnore) { - Text(stringResource(Res.string.update_action_ignore)) - } - PrimaryButton(onClick = onUpdate) { - Text(stringResource(Res.string.update_action_update)) - } - }, - ) { + title = stringResource( + Res.string.plugin_update_dialog_title, + update.pluginName, + update.info.latestVersion)) { Text( text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp)) if (update.info.releaseNotes.isNotBlank()) { SelectionContainer { Markdown(content = update.info.releaseNotes) @@ -85,5 +68,15 @@ fun PluginUpdateDialog( } else { Text(stringResource(Res.string.plugin_update_dialog_no_notes)) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt index 03f4f53b..187dd94d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt @@ -39,8 +39,7 @@ data class AvailablePluginUpdate( val pluginId: String, val pluginName: String, val currentVersion: String, - val info: PluginUpdateInfo, -) + val info: PluginUpdateInfo) /** * Checks the currently active plugins for updates through their own [PluginService.use] @@ -52,8 +51,7 @@ data class AvailablePluginUpdate( */ class PluginUpdateService( private val pluginManager: PluginManager, - private val settingsRepository: SettingsRepository, -) : KoinComponent { + private val settingsRepository: SettingsRepository) : KoinComponent { private val logger = Logger.withTag("PluginUpdateService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -94,8 +92,7 @@ class PluginUpdateService( pluginId = pluginEntry.id, pluginName = pluginEntry.name, currentVersion = pluginEntry.version, - info = updateInfo, - ) + info = updateInfo) return } } catch (e: Exception) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt index b5ea2ede..76e07713 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt @@ -38,8 +38,7 @@ internal val welcomePrimaryLabels: List = listOf( Res.string.welcome_lets_go, Res.string.welcome_next, Res.string.welcome_next, - Res.string.welcome_get_started, -) + Res.string.welcome_get_started) internal val welcomeDonations: List = listOf( DonationOption( @@ -47,20 +46,16 @@ internal val welcomeDonations: List = listOf( title = Res.string.welcome_donate_patreon_title, subtitle = Res.string.welcome_donate_patreon_subtitle, url = "https://patreon.com/krtirtho", - icon = Iconsax.BrandPatreon, - ), + icon = Iconsax.BrandPatreon), DonationOption( id = "buymeacoffee", title = Res.string.welcome_donate_bmac_title, subtitle = Res.string.welcome_donate_bmac_subtitle, url = "https://www.buymeacoffee.com/krtirtho", - icon = Iconsax.BrandBuyMeACoffee, - ), + icon = Iconsax.BrandBuyMeACoffee), DonationOption( id = "opencollective", title = Res.string.welcome_donate_opencollective_title, subtitle = Res.string.welcome_donate_opencollective_subtitle, url = "https://opencollective.com/spotube", - icon = Iconsax.BrandOpencollective, - ), -) + icon = Iconsax.BrandOpencollective)) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt index b8199ef9..e5a293b6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt @@ -26,8 +26,7 @@ data class DonationOption( val title: StringResource, val subtitle: StringResource, val url: String, - val icon: ImageVector, -) + val icon: ImageVector) data class WelcomeUiState( val currentPageIndex: Int, @@ -35,5 +34,4 @@ data class WelcomeUiState( val isLastPage: Boolean, val primaryLabel: StringResource, val donations: List, - val demoPlaying: Boolean = true, -) + val demoPlaying: Boolean = true) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt index 6240e22d..3e19bf53 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt @@ -69,8 +69,7 @@ class WelcomeRepository(private val database: Database) { */ val shouldShowOnboarding: StateFlow = combine( onboardingCompleted, - sessionDismissed, - ) { completed, dismissed -> + sessionDismissed) { completed, dismissed -> when { dismissed -> false DebugConfig.FORCE_SHOW_ONBOARDING -> true @@ -79,8 +78,7 @@ class WelcomeRepository(private val database: Database) { }.stateIn( scope = scope, started = SharingStarted.Eagerly, - initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall, - ) + initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall) init { // One-time migration. When the flag was never written this is either a brand-new install diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt index 61311d76..8eaa84c0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.modules.welcome + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn @@ -38,22 +42,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.base.GhostButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.welcome.pages.BrandingPage import dev.krtirtho.spotube.modules.welcome.pages.DonationPage @@ -72,54 +71,45 @@ import spotube.composeapp.generated.resources.welcome_skip * rendered inside the app shell. Uses the application's base controls and keeps plugin setup * in-place, with a neutral palette independent of the user's chosen accent. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WelcomeScreen(viewModel: WelcomeViewModel) { val state by viewModel.uiState.collectAsStateWithLifecycle() WelcomeTheme { Box(Modifier.fillMaxSize()) { WelcomeBackground() - BaseScaffold( - containerColor = Color.Transparent, - topBar = { - ApplicationMainBar( - title = { - WelcomeWordmark( - fontSize = MaterialTheme.typography.titleLarge.fontSize, - ) - }, - backButton = false, - transparent = true, - actions = { - if (!state.isLastPage) { - GhostButton(onClick = viewModel::completeOnboarding) { - Text( - text = stringResource(Res.string.welcome_skip), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + WelcomeWordmark( + fontSize = AppTheme.typography.titleLarge.fontSize) + }, + backButton = false, + transparent = true, + actions = { + if (!state.isLastPage) { + MillenniumButton( + onClick = viewModel::completeOnboarding, + style = MillenniumButtonStyle.Quiet) { + Text( + text = stringResource(Res.string.welcome_skip), + color = AppTheme.colors.onSurfaceVariant) } - }, - ) - }, - ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize()) { + } + }) + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { // Central stage (animated page content) Box( modifier = Modifier .weight(1f) .widthIn(max = 1280.dp) .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { AnimatedContent( modifier = Modifier.fillMaxSize(), targetState = state.currentPageIndex, @@ -132,15 +122,13 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { (slideOutHorizontally { it / 4 } + fadeOut()) } }, - label = "welcome_page", - ) { index -> + label = "welcome_page") { index -> when (index) { 0 -> BrandingPage() 1 -> DonationPage( donations = state.donations, - onDonate = viewModel::openDonation, - ) + onDonate = viewModel::openDonation) 2 -> PluginsPage() @@ -160,17 +148,15 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { .widthIn(max = 680.dp) .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { // Left: Back button if (state.currentPageIndex > 0) { - OutlineButton( + MillenniumButton( onClick = viewModel::previousPage, - ) { + style = MillenniumButtonStyle.Secondary) { Text( text = stringResource(Res.string.welcome_back), - color = MaterialTheme.colorScheme.onSurface, - ) + color = AppTheme.colors.onSurface) } } else { Spacer(Modifier.width(76.dp)) @@ -180,27 +166,28 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { PageIndicator( pageCount = state.pageCount, - currentPageIndex = state.currentPageIndex, - ) + currentPageIndex = state.currentPageIndex) } // Right: Primary Next / Let's Go action - OutlineButton( + MillenniumButton( onClick = viewModel::onPrimary, - ) { - Text( - text = stringResource(state.primaryLabel), - fontWeight = FontWeight.SemiBold, - ) - Icon( - imageVector = if (state.isLastPage) { - Iconsax.IconsaxCheckCircle - } else { - Iconsax.IconsaxNext - }, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(state.primaryLabel), + fontWeight = FontWeight.SemiBold) + MillenniumIcon( + imageVector = if (state.isLastPage) { + Iconsax.IconsaxCheckCircle + } else { + Iconsax.IconsaxNext + }, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } } } } @@ -214,32 +201,28 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { private fun PageIndicator( pageCount: Int, currentPageIndex: Int, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Row( modifier = modifier .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { repeat(pageCount) { index -> val selected = index == currentPageIndex val width by animateDpAsState( targetValue = if (selected) 20.dp else 6.dp, - label = "welcome_page_indicator", - ) + label = "welcome_page_indicator") Box( modifier = Modifier .size(width = width, height = 6.dp) .clip(CircleShape) .background( if (selected) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.outlineVariant + AppTheme.colors.outlineVariant } - ), - ) + )) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt index b2ee940a..296c2d11 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt @@ -28,8 +28,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class WelcomeViewModel( - private val repository: WelcomeRepository, -) : ViewModel() { + private val repository: WelcomeRepository) : ViewModel() { private val currentPageIndex = MutableStateFlow(0) private val demoPlaying = MutableStateFlow(true) @@ -42,8 +41,7 @@ class WelcomeViewModel( isLastPage = index == pageCount - 1, primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], donations = welcomeDonations, - demoPlaying = playing, - ) + demoPlaying = playing) } .stateIn( scope = viewModelScope, @@ -53,9 +51,7 @@ class WelcomeViewModel( pageCount = welcomePrimaryLabels.size, isLastPage = welcomePrimaryLabels.size == 1, primaryLabel = welcomePrimaryLabels.first(), - donations = welcomeDonations, - ), - ) + donations = welcomeDonations)) fun nextPage() { currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt index c06a5b98..cf815c99 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt @@ -20,77 +20,46 @@ package dev.krtirtho.spotube.modules.welcome import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.TextUnit -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.theme.MillenniumTheme import spotube.composeapp.generated.resources.* -/** App-native controls and surfaces, in neutral charcoal/ivory instead of the user's accent. */ +/** Installs the Millennium theme for the onboarding pages. */ @Composable fun WelcomeTheme(content: @Composable () -> Unit) { - val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f - val scheme = if (dark) darkColorScheme( - primary = Color(0xFF8BA8DD), onPrimary = Color(0xFF10213E), - primaryContainer = Color(0xFF263754), onPrimaryContainer = Color(0xFFD9E4F7), - secondary = Color(0xFFBDBDBD), secondaryContainer = Color(0xFF343434), - onSecondaryContainer = Color(0xFFF0F0F0), - surface = Color(0xFF181818), background = Color(0xFF181818), - surfaceContainer = Color(0xFF222222), surfaceContainerHigh = Color(0xFF292929), - surfaceContainerHighest = Color(0xFF323232), - onSurface = Color(0xFFEDEBE7), onSurfaceVariant = Color(0xFFABA9A5), - outlineVariant = Color(0xFF414141), - ) else lightColorScheme( - primary = Color(0xFF244879), onPrimary = Color.White, - primaryContainer = Color(0xFFDDE5F1), onPrimaryContainer = Color(0xFF173252), - secondaryContainer = Color(0xFFE5E3DF), onSecondaryContainer = Color(0xFF292929), - surface = Color(0xFFF4F3F0), background = Color(0xFFF4F3F0), - surfaceContainer = Color(0xFFEDEBE7), surfaceContainerHigh = Color(0xFFFDFCFA), - surfaceContainerHighest = Color(0xFFE1DFDB), - onSurface = Color(0xFF252525), onSurfaceVariant = Color(0xFF696762), - outlineVariant = Color(0xFFD2D0CC), - ) - MaterialTheme(colorScheme = scheme) { - val baseTheme = rememberBaseUITheme() - CompositionLocalProvider( - LocalBaseUITheme provides baseTheme, - LocalBaseUIColors provides baseTheme.colors, - ) { - content() - } + val dark = AppTheme.colors.surface.luminance() < 0.5f + MillenniumTheme(dark = dark) { + content() } } @Composable fun WelcomeBackground(modifier: Modifier = Modifier) { - val scheme = MaterialTheme.colorScheme + val scheme = AppTheme.colors Box( modifier.fillMaxSize().background( - Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface)), - ) + Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface))) ) } @Composable fun WelcomeWordmark( modifier: Modifier = Modifier, - fontSize: TextUnit = MaterialTheme.typography.displaySmall.fontSize + fontSize: TextUnit = AppTheme.typography.displaySmall.fontSize ) { Text( stringResource(Res.string.welcome_app_name), modifier = modifier, fontFamily = FontFamily(Font(Res.font.cookie_regular)), fontSize = fontSize, - color = MaterialTheme.colorScheme.onSurface, - ) + color = AppTheme.colors.onSurface) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt index c0f432f6..42696f1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt @@ -4,8 +4,8 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,24 +23,21 @@ fun BrandingPage(modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp)) Spacer(Modifier.height(24.dp)) WelcomeWordmark(fontSize = 48.sp) Spacer(Modifier.height(24.dp)) Text( stringResource(Res.string.welcome_brand_freedom), - style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) Spacer(Modifier.height(16.dp)) Text( stringResource(Res.string.welcome_brand_quiet_description), modifier = Modifier.widthIn(max = 440.dp), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt index 39dcbaf5..8663aa17 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt @@ -1,11 +1,15 @@ package dev.krtirtho.spotube.modules.welcome.pages + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -13,7 +17,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.modules.welcome.DonationOption import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* @@ -23,38 +26,38 @@ import spotube.composeapp.generated.resources.* fun DonationPage( donations: List, onDonate: (String) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Column( modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { Text( stringResource(Res.string.welcome_donate_quiet_title), - style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) Spacer(Modifier.height(20.dp)) Text( stringResource(Res.string.welcome_donate_letter), modifier = Modifier.widthIn(max = 540.dp), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) Spacer(Modifier.height(32.dp)) FlowRow( horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { donations.forEach { option -> - OutlineButton(onClick = { onDonate(option.url) }) { - Icon( - option.icon, null, Modifier.size(24.dp), - tint = if (option.id == "patreon") MaterialTheme.colorScheme.onSurface else Color.Unspecified, - ) - Text(stringResource(option.title)) + MillenniumButton( + onClick = { onDonate(option.url) }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + option.icon, null, Modifier.size(24.dp), + tint = if (option.id == "patreon") AppTheme.colors.onSurface else Color.Unspecified) + Text(stringResource(option.title)) + } } } } @@ -62,9 +65,8 @@ fun DonationPage( Text( stringResource(Res.string.welcome_donate_optional), modifier = Modifier.widthIn(max = 500.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt index bfd8a2b1..80b6b4eb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -1,5 +1,10 @@ package dev.krtirtho.spotube.modules.welcome.pages + +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -7,9 +12,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -21,8 +26,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.resources.iconsax.* import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* @@ -41,16 +44,14 @@ fun HowItWorksPage( Row( Modifier.fillMaxSize().padding(40.dp), horizontalArrangement = Arrangement.spacedBy(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f)) ListeningIntroduction(Modifier.weight(1f)) } } else { Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically), - ) { + verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)) { ListeningIntroduction() PlayerDemonstration(isPlaying, onTogglePlayback) } @@ -63,13 +64,11 @@ private fun ListeningIntroduction(modifier: Modifier = Modifier) { Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { Text( stringResource(Res.string.welcome_how_quiet_title), - style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif), - ) + style = AppTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif)) Text( stringResource(Res.string.welcome_how_quiet_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant) } } @@ -89,15 +88,14 @@ private fun PlayerDemonstration( 0f, (2 * PI).toFloat(), infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter" ) - val scheme = MaterialTheme.colorScheme + val scheme = AppTheme.colors - Card(modifier.fillMaxWidth()) { + MillenniumCard(modifier = modifier.fillMaxWidth()) { Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { Text( stringResource(if (isPlaying) Res.string.welcome_demo_streaming else Res.string.welcome_demo_paused), - style = MaterialTheme.typography.labelSmall, - color = scheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = scheme.onSurfaceVariant) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(20.dp) @@ -124,11 +122,11 @@ private fun PlayerDemonstration( Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( stringResource(Res.string.welcome_how_mockup_title), - style = MaterialTheme.typography.titleLarge + style = AppTheme.typography.titleLarge ) Text( stringResource(Res.string.welcome_how_mockup_artist), - style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant + style = AppTheme.typography.bodySmall, color = scheme.onSurfaceVariant ) Canvas(Modifier.fillMaxWidth().height(32.dp)) { val count = 18 @@ -147,29 +145,32 @@ private fun PlayerDemonstration( } Text( stringResource(Res.string.welcome_how_mockup_lyric), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif), + style = AppTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif), color = scheme.onSurfaceVariant ) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - Icon( + MillenniumIcon( Iconsax.IconsaxPrevious, null, Modifier.size(18.dp), tint = scheme.onSurfaceVariant ) Spacer(Modifier.width(24.dp)) - GhostIconButton(onClick = onTogglePlayback, modifier = Modifier.size(48.dp)) { - Icon( + MillenniumIconButton( + onClick = onTogglePlayback, + modifier = Modifier.size(48.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, stringResource(if (isPlaying) Res.string.welcome_demo_pause_action else Res.string.welcome_demo_play_action), Modifier.size(24.dp) ) } Spacer(Modifier.width(24.dp)) - Icon( + MillenniumIcon( Iconsax.IconsaxNext, null, Modifier.size(18.dp), diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index d0a2aa10..3bc272c8 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -282,8 +282,7 @@ class DeviceAudioPlayerQueueTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE))) queue.jumpTo(1, autoPlay = true) @@ -296,8 +295,7 @@ class DeviceAudioPlayerQueueTest { fun `jumpTo without autoPlay does not call play`() = runTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( - MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) queue.jumpTo(0, autoPlay = false) @@ -309,8 +307,7 @@ class DeviceAudioPlayerQueueTest { fun `jumpTo clamps out of range index`() = runTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( - MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) queue.jumpTo(999) diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt index 0a204cfd..1b59eaaa 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt @@ -34,8 +34,7 @@ class FakeMetadataTrackAPI( class FakePluginService( private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), - override val pluginId: String = "fake-plugin", -) : PluginService { + override val pluginId: String = "fake-plugin") : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) override suspend fun start() = Unit diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt index 8b0043e9..c7857fe4 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt @@ -68,8 +68,7 @@ class ScrobbleManagerTest { audioPlayer = player, audioPlayerQueue = queue, scrobblePluginSource = pluginSource, - scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)), - ) + scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler))) } private fun playSeconds(seconds: Int) { @@ -86,8 +85,7 @@ class ScrobbleManagerTest { streamingEntry( id = "t1", durationMs = 200_000, - externalUri = "https://open.spotify.com/track/t1", - ) + externalUri = "https://open.spotify.com/track/t1") ) player.setPlayerState(PlayerState.PLAYING) player.setDuration(200.seconds) @@ -241,8 +239,7 @@ class ScrobbleManagerTest { localEntry( name = "Local Song", url = "file:///music/local.mp3", - duration = 100_000, - ) + duration = 100_000) ) player.setPlayerState(PlayerState.PLAYING) @@ -273,14 +270,12 @@ class ScrobbleManagerTest { private fun streamingTrack( id: String, durationMs: Long = 200_000, - externalUri: String? = null, - ): MetadataTrack { + externalUri: String? = null): MetadataTrack { val artist = MetadataArtist.Basic( id = "artist-$id", name = "Artist $id", thumbnails = emptyList(), - externalUri = null, - ) + externalUri = null) val album = MetadataAlbum.Detailed( releaseDate = "2024", genres = emptyList(), @@ -291,8 +286,7 @@ class ScrobbleManagerTest { thumbnails = emptyList(), albumType = MetadataAlbumType.Album, artists = listOf(artist), - externalUri = null, - ) + externalUri = null) return MetadataTrack( id = id, title = "Track $id", @@ -305,34 +299,29 @@ class ScrobbleManagerTest { explicit = false, popularity = null, isrcCode = null, - externalUri = externalUri, - ) + externalUri = externalUri) } private fun streamingEntry( id: String, durationMs: Long = 200_000, - externalUri: String? = null, - ): QueueEntry.StreamingTrack { + externalUri: String? = null): QueueEntry.StreamingTrack { return QueueEntry.StreamingTrack( track = streamingTrack(id, durationMs, externalUri), - url = "http://127.0.0.1:14769/stream/$id", - ) + url = "http://127.0.0.1:14769/stream/$id") } private fun localEntry( name: String = "Local Track", url: String = "file:///music/local.mp3", - duration: Long = 180_000, - ): QueueEntry.LocalTrack { + duration: Long = 180_000): QueueEntry.LocalTrack { return QueueEntry.LocalTrack( name = name, artists = listOf("Local Artist"), duration = duration, album = "Local Album", coverBytes = null, - url = url, - ) + url = url) } private class FakeQueue : AudioPlayerQueue { @@ -354,14 +343,12 @@ class ScrobbleManagerTest { entries: List, autoPlay: Boolean, startPosition: Int, - collectionEntry: QueueCollectionEntry?, - ) = error("not used") + collectionEntry: QueueCollectionEntry?) = error("not used") override suspend fun addToQueue(entry: QueueEntry) = error("not used") override suspend fun addAllToQueue( entries: List, - collectionEntry: QueueCollectionEntry?, - ) = error("not used") + collectionEntry: QueueCollectionEntry?) = error("not used") override suspend fun addAllAfterCurrent(entries: List) = error("not used") override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") @@ -377,8 +364,7 @@ class ScrobbleManagerTest { } private class FakeScrobblePluginSource( - initialPlugin: PluginService? = null, - ) : ScrobblePluginSource { + initialPlugin: PluginService? = null) : ScrobblePluginSource { private val selected = MutableStateFlow(initialPlugin) override val selectedScrobblePlugin: StateFlow = selected diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index 8b0fbf82..3c70a4f0 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -56,8 +56,7 @@ class StreamingUrlRepositoryBehaviorTest { explicit = false, popularity = null, isrcCode = null, - externalUri = null, - ) + externalUri = null) private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic( id = id, @@ -66,14 +65,12 @@ class StreamingUrlRepositoryBehaviorTest { album = null, thumbnails = emptyList(), externalUri = null, - confidence = confidence, - ) + confidence = confidence) private fun streamedSource( sourceId: String, streams: List, - confidence: Float = 1f, - ) = AudioSource.Streamed( + confidence: Float = 1f) = AudioSource.Streamed( id = sourceId, title = "source-$sourceId", artist = null, @@ -81,16 +78,14 @@ class StreamingUrlRepositoryBehaviorTest { thumbnails = emptyList(), externalUri = null, confidence = confidence, - streams = streams, - ) + streams = streams) private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy( url = url, codec = "opus", container = "webm", protocol = StreamProtocol.PROGRESSIVE, - bitrate = bitrate, - ) + bitrate = bitrate) private class Harness(cacheTtlMs: Long = 30_000) { val queue = FakeStreamingAudioPlayerQueue() @@ -102,8 +97,7 @@ class StreamingUrlRepositoryBehaviorTest { audioPlayerQueue = queue, matchedTracksRepository = matchedTracksRepository, settingsRepository = settings, - streamUrlCacheTtlMs = cacheTtlMs, - ) + streamUrlCacheTtlMs = cacheTtlMs) fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { val service = FakeAudioPluginService(pluginId, audioAPI) @@ -219,9 +213,7 @@ class StreamingUrlRepositoryBehaviorTest { url = "https://example.com/aac.mp4", codec = "aac", container = "mp4", - bitrate = 256_000, - ), - ) + bitrate = 256_000)) ) ) } diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt index 27e5550f..a52298dc 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -19,8 +19,7 @@ import kotlinx.coroutines.flow.StateFlow class FakeAudioAPI( override val supportedQualities: List = emptyList(), var onGetStreamsByTrack: suspend (MetadataTrack) -> List = { emptyList() }, - var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }, -) : AudioAPI { + var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }) : AudioAPI { var getStreamsByTrackCallCount = 0 var getStreamsOfAudioSourceCallCount = 0 val getStreamsOfAudioSourceCalls = mutableListOf() @@ -40,8 +39,7 @@ class FakeAudioAPI( /** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */ class FakeAudioPluginService( override val pluginId: String, - val audioAPI: FakeAudioAPI = FakeAudioAPI(), -) : PluginService { + val audioAPI: FakeAudioAPI = FakeAudioAPI()) : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) override suspend fun start() = Unit @@ -116,8 +114,7 @@ class FakeStreamingAudioPlayerQueue : AudioPlayerQueue { entries: List, autoPlay: Boolean, startPosition: Int, - collectionEntry: QueueCollectionEntry?, - ) = error("not used") + collectionEntry: QueueCollectionEntry?) = error("not used") override suspend fun addToQueue(entry: QueueEntry) = error("not used") override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt index 97f72cc4..d11fe33e 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -32,34 +32,29 @@ class StreamingUrlRepositoryTest { bitrate: Int, codec: String = "opus", container: String = "webm", - protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, - ) = AudioStream.Lossy( + protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy( url = url, codec = codec, container = container, protocol = protocol, - bitrate = bitrate, - ) + bitrate = bitrate) private fun lossless( url: String, sampleRate: Int, channels: Int, codec: String = "flac", - container: String = "flac", - ) = AudioStream.Lossless( + container: String = "flac") = AudioStream.Lossless( url = url, codec = codec, container = container, sampleRate = sampleRate, - channels = channels, - ) + channels = channels) private fun select( streams: List, format: AudioFormat = opusWebm(), - quality: AudioQuality = AudioQuality.Lossy(256_000), - ) = selectPreferredAudioStream(streams, format, quality) + quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality) // ---------- empty / single ---------- @@ -78,8 +73,7 @@ class StreamingUrlRepositoryTest { fun `does not mutate the input list`() { val streams = mutableListOf( lossy("opus-44", 44_000), - lossy("opus-256", 256_000), - ) + lossy("opus-256", 256_000)) val snapshot = streams.toList() select(streams) @@ -96,8 +90,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(otherFormat, preferredFormat), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(preferredFormat, result) } @@ -109,8 +102,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(mismatch, exact), - format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList()), - ) + format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList())) assertSame(exact, result) } @@ -122,8 +114,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(fullMismatch, partial), - quality = AudioQuality.Lossy(44_000), - ) + quality = AudioQuality.Lossy(44_000)) assertSame(partial, result) } @@ -145,8 +136,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(codecOnly, containerOnly), - quality = AudioQuality.Lossy(44_000), - ) + quality = AudioQuality.Lossy(44_000)) assertSame(containerOnly, result) } @@ -159,8 +149,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(low, target, high), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(target, result) } @@ -173,8 +162,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(wrongFormatRightFamily, sameFormatWrongFamily), - quality = AudioQuality.Lossy(44_000), - ) + quality = AudioQuality.Lossy(44_000)) assertSame(sameFormatWrongFamily, result) } @@ -189,8 +177,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(low, exact, high), - quality = AudioQuality.Lossy(128_000), - ) + quality = AudioQuality.Lossy(128_000)) assertSame(exact, result) } @@ -202,8 +189,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(higher, lower), - quality = AudioQuality.Lossy(200_000), - ) + quality = AudioQuality.Lossy(200_000)) assertSame(lower, result) } @@ -215,8 +201,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(lower, higher), - quality = AudioQuality.Lossy(100_000), - ) + quality = AudioQuality.Lossy(100_000)) assertSame(higher, result) } @@ -237,8 +222,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(losslessStream, lossyStream), - quality = AudioQuality.Lossy(128_000), - ) + quality = AudioQuality.Lossy(128_000)) assertSame(lossyStream, result) } @@ -250,8 +234,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(min, max), - quality = AudioQuality.Lossy(Int.MAX_VALUE), - ) + quality = AudioQuality.Lossy(Int.MAX_VALUE)) assertSame(max, result) } @@ -267,8 +250,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(otherRate, mono, exact), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(exact, result) } @@ -281,8 +263,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(far, near), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(near, result) } @@ -295,8 +276,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(mono, stereo), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(stereo, result) } @@ -309,8 +289,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(lossyStream, losslessStream), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(losslessStream, result) } @@ -325,8 +304,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(first, second), format = flac(), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(first, result) } @@ -338,8 +316,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(first, second), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(first, result) } @@ -354,8 +331,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(low, target), format = AudioFormat(codec = "", container = "", qualities = emptyList()), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(target, result) } @@ -376,8 +352,7 @@ class StreamingUrlRepositoryTest { val streams = listOf( lossy("opus-44", 44_000), lossy("opus-128", 128_000), - lossy("opus-256", 256_000), - ) + lossy("opus-256", 256_000)) val first = select(streams, quality = AudioQuality.Lossy(100_000)) val second = select(streams, quality = AudioQuality.Lossy(100_000)) @@ -405,24 +380,21 @@ class StreamingUrlRepositoryTest { fun `relative hls manifest paths are resolved against the youtube origin`() { assertEquals( "https://www.youtube.com/watch?v=abc", - normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS), - ) + normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS)) } @Test fun `relative dash manifest paths are resolved against the youtube origin`() { assertEquals( "https://www.youtube.com/watch?v=abc", - normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH), - ) + normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH)) } @Test fun `relative progressive urls are returned unchanged`() { assertEquals( "/watch?v=abc", - normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE), - ) + normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE)) } @Test diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt index a17253b2..d7dca15e 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.SupervisorJob import org.koin.core.component.KoinComponent import org.koin.core.component.inject - fun MainViewController() = ComposeUIViewController(configure = { initKoin() }) { InitNewPipe { App() } } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt index 04efce38..016993eb 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -343,7 +343,6 @@ private fun CMTimeMake(value: Long, timescale: Int): CValue { return platform.CoreMedia.CMTimeMake(value, timescale) } - private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue { return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale) } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt index 9c07b0ed..16820c4f 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt @@ -72,8 +72,7 @@ actual class NewPipeService { url = stream.content, codec = stream.codec ?: "unknown", bitrate = stream.bitrate, - container = stream.format?.suffix ?: "unknown", - ) + container = stream.format?.suffix ?: "unknown") }, videoStreams = listOf(), // Not yet supported id = id diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt index 0cde016d..516a4ccc 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt @@ -35,8 +35,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return requireNotNull(cacheDirectory?.path) } @@ -48,8 +47,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return requireNotNull(documentDirectory?.path) } @@ -60,8 +58,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return (downloadsDirectory?.path ?: getApplicationDataDirPath()) + "/Spotube" } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt index f47d02dd..dbc48426 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt @@ -24,8 +24,7 @@ class IosLocalMediaDiscoveryService : LocalMediaDiscoveryService { override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { return null } } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt index 24a2efe0..9de9e195 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt @@ -23,6 +23,5 @@ import androidx.compose.runtime.Composable actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { return LocalMediaPermissionState( isGranted = true, - requestPermission = {}, - ) + requestPermission = {}) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt index 56a44ffb..9ddcd48b 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -122,8 +122,7 @@ 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) } @@ -185,8 +184,7 @@ 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 { diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt index 05281c43..a227be0a 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt @@ -44,8 +44,7 @@ import kotlin.time.Duration.Companion.milliseconds * dispatched onto [scope] so they reach the player's suspend API. */ class SystemMediaControlService( - private val audioPlayer: AudioPlayerInterface, -) : KoinComponent, AutoCloseable { + private val audioPlayer: AudioPlayerInterface) : KoinComponent, AutoCloseable { private val logger by injectLogger() @@ -105,12 +104,10 @@ class SystemMediaControlService( scope.launch { combine( audioPlayer.playerStateFlow, - audioPlayer.positionFlow, - ) { state, position -> + audioPlayer.positionFlow) { state, position -> MediaPlaybackState( status = state.toMediaPlaybackStatus(), - positionMs = position.inWholeMilliseconds, - ) + positionMs = position.inWholeMilliseconds) }.collect { MediaControlService.setPlaybackState(it) } } @@ -127,8 +124,7 @@ class SystemMediaControlService( artist = artist, album = album, coverUrl = coverURL.ifBlank { null }, - duration = duration.inWholeMilliseconds, - ) + duration = duration.inWholeMilliseconds) } private fun PlayerState.toMediaPlaybackStatus(): MediaPlaybackStatus { diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt index bb3e4d29..2dd89abe 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt @@ -38,16 +38,14 @@ import org.koin.compose.koinInject fun NucleusApplicationScope.SystemTray( isWindowVisible: Boolean, onToggleWindowVisibility: () -> Unit, - onExit: () -> Unit, -) { + onExit: () -> Unit) { val trayService = koinInject() val state by trayService.state.collectAsState() Tray( icon = Iconsax.IconsaxMusic, tooltip = "Spotube", - primaryAction = onToggleWindowVisibility, - ) { + primaryAction = onToggleWindowVisibility) { Item(label = if (isWindowVisible) "Hide Window" else "Show Window") { onToggleWindowVisibility() } @@ -63,8 +61,7 @@ fun NucleusApplicationScope.SystemTray( CheckableItem( label = "Shuffle", checked = state.shuffleEnabled, - onCheckedChange = trayService::setShuffle, - ) + onCheckedChange = trayService::setShuffle) SubMenu(label = "Loop") { Item(label = "Loop: OFF") { trayService.setLoop(LoopState.NONE) } Item(label = "Loop: ONE") { trayService.setLoop(LoopState.ONE) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt index b9dfe02c..c4baf2a7 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt @@ -44,8 +44,7 @@ import kotlinx.coroutines.launch class SystemTrayService( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val savedTracksRepository: SavedTracksRepository, -) : AutoCloseable { + private val savedTracksRepository: SavedTracksRepository) : AutoCloseable { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -61,8 +60,7 @@ class SystemTrayService( audioPlayer.shuffleModeFlow, audioPlayer.volumeFlow, audioPlayerQueue.currentQueueEntryFlow, - savedTracksRepository.savedTracksIdsFlow, - ) { values -> + savedTracksRepository.savedTracksIdsFlow) { values -> TrayState( playerState = values[0] as PlayerState, mediaItem = values[1] as MediaItem?, @@ -70,8 +68,7 @@ class SystemTrayService( shuffleEnabled = values[3] as Boolean, volume = values[4] as Float, currentEntry = values[5] as QueueEntry?, - savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set), - ) + savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set)) }.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial) fun setCallbacks(onToggleWindowVisibility: () -> Unit) { @@ -137,8 +134,7 @@ class SystemTrayService( val shuffleEnabled: Boolean, val volume: Float, val currentEntry: QueueEntry?, - val savedTrackIds: Set, - ) { + val savedTrackIds: Set) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val currentTrackId: String? get() = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -155,8 +151,7 @@ class SystemTrayService( shuffleEnabled = false, volume = 1f, currentEntry = null, - savedTrackIds = emptySet(), - ) + savedTrackIds = emptySet()) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index a55f1c85..e5012afe 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -26,12 +30,8 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.window.WindowDraggableArea -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults + +import org.spotube.millennium.composables.MillenniumTopAppBar import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -41,7 +41,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.FluentMaximize @@ -54,22 +53,18 @@ import dev.nucleusframework.window.WindowDoubleClickAction import dev.nucleusframework.window.windowDragArea import org.koin.compose.koinInject - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), backButton: Boolean, - transparent: Boolean, -) { - val colors = LocalBaseUIColors.current + transparent: Boolean) { Box( modifier = Modifier.fillMaxWidth() .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize) ) { - TopAppBar( + MillenniumTopAppBar( title = title, actions = { actions() @@ -80,24 +75,6 @@ actual fun ApplicationMainBar( } }, subtitle = subtitle, - colors = if (transparent) { - TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, - titleContentColor = colors.onSurface, - actionIconContentColor = colors.onSurfaceVariant, - navigationIconContentColor = colors.onSurfaceVariant, - ) - } else { - // The app bar follows the selected surface theme rather than Material's. - TopAppBarDefaults.topAppBarColors( - containerColor = colors.surface, - scrolledContainerColor = colors.surfaceContainer, - titleContentColor = colors.onSurface, - actionIconContentColor = colors.onSurfaceVariant, - navigationIconContentColor = colors.onSurfaceVariant, - ) - }, modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons ) LocalWindowScope.current.WindowControls( @@ -119,27 +96,33 @@ private fun WindowButtons(modifier: Modifier = Modifier) { modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton(onClick = { windowState.isMinimized = true }) { - Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)) + MillenniumIconButton( + onClick = { windowState.isMinimized = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)) } - IconButton(onClick = { - windowState.placement = if (windowState.placement == WindowPlacement.Maximized) - WindowPlacement.Floating else WindowPlacement.Maximized - }) { + MillenniumIconButton( + onClick = { + windowState.placement = if (windowState.placement == WindowPlacement.Maximized) + WindowPlacement.Floating else WindowPlacement.Maximized + }, + style = MillenniumIconButtonStyle.Quiet) { if (windowState.placement == WindowPlacement.Floating) { - Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)) } else { - Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)) } } - IconButton(onClick = { - if (settings?.minimizeToTray == true) { - trayService.hideWindow() - } else { - applicationScope.exitApplication() - } - }) { - Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)) + MillenniumIconButton( + onClick = { + if (settings?.minimizeToTray == true) { + trayService.hideWindow() + } else { + applicationScope.exitApplication() + } + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt index bc56ea24..25f984c1 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt @@ -30,14 +30,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors +import dev.krtirtho.spotube.core.ui.theming.AppTheme @Composable actual fun VerticalScrollbar( listState: LazyListState, modifier: Modifier ) { - val colors = LocalBaseUIColors.current + val colors = AppTheme.colors CompositionLocalProvider( LocalScrollbarStyle provides ScrollbarStyle( minimalHeight = 16.dp, @@ -45,15 +45,12 @@ actual fun VerticalScrollbar( shape = RoundedCornerShape(4.dp), hoverDurationMillis = 300, unhoverColor = colors.onSurface.copy(alpha = 0.18f), - hoverColor = colors.onSurface.copy(alpha = 0.4f), - ), - ) { + hoverColor = colors.onSurface.copy(alpha = 0.4f))) { VerticalScrollbar( adapter = rememberScrollbarAdapter(listState), modifier = modifier.fillMaxHeight() .padding(end = 2.dp) .padding(vertical = 16.dp) - .width(12.dp), - ) + .width(12.dp)) } } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 2a203feb..9cc41de2 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -58,7 +58,6 @@ private object KoinServicesProvider : KoinComponent { val settingsProvider: SettingsProvider get() = get() } - @OptIn(ExperimentalComposeUiApi::class) fun main() { FileKit.init(appId = "dev.krtirtho.spotube") @@ -93,8 +92,7 @@ fun main() { SystemTray( isWindowVisible = isWindowVisible, onToggleWindowVisibility = onToggleWindowVisibility, - onExit = onExit, - ) + onExit = onExit) } DecoratedWindow( @@ -109,8 +107,7 @@ fun main() { } }, title = "Spotube", - visible = isWindowVisible, - ) { + visible = isWindowVisible) { CompositionLocalProvider( LocalApplicationScope provides this@nucleusApplication, LocalWindowScope provides this@DecoratedWindow, diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt index fa2b1cf5..405ef268 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt @@ -70,8 +70,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), durationMs = metadata?.durationMs ?: 0L, album = metadata?.album, - coverBytes = metadata?.coverBytes, - ) + coverBytes = metadata?.coverBytes) folders.getOrPut(parent) { mutableListOf() }.add(track) } } @@ -85,16 +84,14 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { LocalMediaFolder( path = path, name = Path.of(path).fileName?.toString().orEmpty().ifBlank { path }, - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } } override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { val resolvedRoots = resolveRoots(roots) if (resolvedRoots.isEmpty()) return null @@ -109,8 +106,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY, - ) + StandardWatchEventKinds.ENTRY_MODIFY) } } } @@ -169,8 +165,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { } else { listOf( Path.of(home, "Music"), - Path.of(home, "Downloads"), - ) + Path.of(home, "Downloads")) } return (defaults + customRoots) @@ -189,7 +184,6 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { "wav", "ogg", "opus", - "wma", - ) + "wma") } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt index 24a2efe0..9de9e195 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt @@ -23,6 +23,5 @@ import androidx.compose.runtime.Composable actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { return LocalMediaPermissionState( isGranted = true, - requestPermission = {}, - ) + requestPermission = {}) } diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt index 537fcfbb..28a9e548 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt @@ -60,8 +60,7 @@ class AudioPlayerTest { val candidates = listOf( File("src/jvmTest/resources/test-audio"), File("../composeApp/src/jvmTest/resources/test-audio"), - File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio"), - ) + File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio")) for (dir in candidates) { if (dir.isDirectory) return dir } @@ -76,8 +75,7 @@ class AudioPlayerTest { duration = 10.seconds, coverURL = "", url = url, - protocol = StreamProtocol.PROGRESSIVE, - ) + protocol = StreamProtocol.PROGRESSIVE) } private fun testItems(count: Int = 3): List { diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt index 37f6b279..fd455041 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt @@ -18,34 +18,21 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import org.spotube.millennium.composables.MillenniumTopAppBar import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), backButton: Boolean, - transparent: Boolean, -) { - return TopAppBar( + transparent: Boolean) { + return MillenniumTopAppBar( title = title, subtitle = subtitle, actions = actions, - colors = if (transparent) { - TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, - ) - } else { - TopAppBarDefaults.topAppBarColors() - }, navigationIcon = { if (backButton) { ApplicationBackButton() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f1e5295..1033de8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,6 @@ carApp = "1.7.0" composeHotReload = "1.2.0" composeMultiplatform = "1.11.1" coil = "3.5.0" -composePlaceholderMaterial3 = "1.0.12" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" haze = "2.0.0-alpha03" @@ -41,9 +40,8 @@ kmpZip = "0.13.0" kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" -material3 = "1.10.0-alpha05" kotlinx-serialization-json = "1.11.0" -materialKolor = "4.1.1" +millennium = "1.0.0" mqttClient = "2.1.1" mqttBuffer = "6.30.8" multiplatformMarkdownRenderer = "0.45.0" @@ -68,7 +66,6 @@ multiplatform-nav3-ui = "1.1.1" compose-multiplatform-adaptive = "1.3.0-alpha05" compose-multiplatform-lifecycle = "2.10.0" feather-icons = "1.1.1" -material3-window-size = "1.9.0" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" @@ -86,7 +83,6 @@ mokkery = "3.3.0" appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } -compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } @@ -112,7 +108,6 @@ androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecyc androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } -compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } @@ -124,7 +119,7 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } -material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } +millennium = { module = "org.spotube.millennium:millennium", version.ref = "millennium" } mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" } mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } @@ -166,12 +161,7 @@ koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } -jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } -jetbrains-material3-window-size = { module = "org.jetbrains.compose.material3:material3-window-size-class", version.ref = "material3-window-size" } -jetbrains-material3-adaptive-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3-window-size" } -jetbrains-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "compose-multiplatform-adaptive" } -jetbrains-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "compose-multiplatform-adaptive" } feather-icons = { module = "br.com.devsrsouza.compose.icons:feather", version.ref = "feather-icons" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } From caad76ba95810447783274c74fffe155ded961f8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Oct 2026 21:08:11 +0600 Subject: [PATCH 038/132] refactor: replace currentWindowAdaptiveInfo with currentWindowWidthBreakpoint for adaptive layouts --- .../composeResources/values/strings.xml | 7 +- .../kotlin/dev/krtirtho/spotube/App.kt | 23 ++- .../ui/component/AdaptiveDialogBottomSheet.kt | 10 +- .../component/AdaptiveDropdownBottomSheet.kt | 49 ++--- .../core/ui/component/CollectionDetails.kt | 7 +- .../core/ui/component/FeaturedCarousel.kt | 6 +- .../spotube/core/ui/component/Sidebar.kt | 133 ------------ .../spotube/core/ui/component/TrackList.kt | 11 +- .../spotube/core/ui/theming/AppTheme.kt | 44 ++-- .../spotube/modules/artist/ArtistScreen.kt | 7 +- .../library/playlist/AddToPlaylistPicker.kt | 9 +- .../library/playlist/PlaylistFormSheet.kt | 9 +- .../playlist/AddTracksToPlaylistDialog.kt | 9 +- .../modules/settings/SettingsScreen.kt | 8 +- .../modules/settings/SettingsSidebarLayout.kt | 9 +- .../components/InteractiveSettingCards.kt | 14 +- .../spotube/modules/shell/AppShell.kt | 42 ++-- .../modules/shell/AppShellViewModel.kt | 9 +- .../spotube/modules/shell/AppSidebar.kt | 5 +- .../AlternativeTrackSheet.kt | 10 +- .../shell/player_queue/PlayerQueueContent.kt | 192 +++++++++++------- .../modules/shell/player_queue/QueueSheet.kt | 33 +-- .../modules/welcome/pages/HowItWorksPage.kt | 9 +- 23 files changed, 289 insertions(+), 366 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a38b2ac1..01b95038 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -16,13 +16,18 @@ --> + Queue + Clear Queue + Filter queue... + No queue entries + Settings Plugins Language and Region Appearance Playback - Downloads + Downloadsf Caching Desktop Updates diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index f7005f50..1ab1f561 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -76,7 +76,8 @@ interface NavigationItem { private val tabs = listOf( NavigationItem.Tab("Home", Iconsax.IconsaxHome, Routes.Home), - NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search)) + NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search) +) val sidebarTabs = tabs + listOf( NavigationItem.Group( @@ -89,7 +90,8 @@ val sidebarTabs = tabs + listOf( LibraryTab.Playlists ), NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), - NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists)) + NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists) + ) ), NavigationItem.Group( "On Device", @@ -105,23 +107,23 @@ val sidebarTabs = tabs + listOf( Iconsax.IconsaxDirectboxReceive, Routes.Library, LibraryTab.Downloads - )) + ) + ) ), NavigationItem.Group( "Connect", items = listOf( NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), - NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam)))) + NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam) + ) + ) +) val bottombarTabs = tabs + listOf( NavigationItem.Tab("Library", Iconsax.IconsaxMusicLibrary, Routes.Library), NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings) ) -@OptIn( - KoinExperimentalAPI::class, - ExperimentalCoroutinesApi::class, - ExperimentalSharedTransitionApi::class -) +@OptIn(KoinExperimentalAPI::class) @Composable fun App( content: @Composable () -> Unit = {} @@ -156,7 +158,8 @@ fun App( AppShell(navigator, navigationState) { SharedTransitionLayout { CompositionLocalProvider( - LocalSharedTransitionScope provides this@SharedTransitionLayout) { + LocalSharedTransitionScope provides this@SharedTransitionLayout + ) { Column { NavDisplay( modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt index 24b21b1a..7d3c25a2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -32,16 +32,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size - -import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent import com.composeunstyled.rememberModalBottomSheetState -import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -51,11 +49,9 @@ fun AdaptiveDialogBottomSheet( onDismiss: () -> Unit, title: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier, - breakpointDp: Float = 600f, actions: @Composable (() -> Unit)? = null, content: @Composable () -> Unit) { - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium if (isLargeScreen) { MillenniumDialog( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index acb5b3a4..e8646ed2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -18,17 +18,8 @@ package dev.krtirtho.spotube.core.ui.component -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumModalBottomSheet -import org.spotube.millennium.composables.MillenniumSeparator -import org.spotube.millennium.composables.MillenniumTextField -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -42,23 +33,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.DropdownMenuPanel -import com.composeunstyled.SheetDetent -import com.composeunstyled.Text -import com.composeunstyled.UnstyledDropdownMenu -import com.composeunstyled.UnstyledDropdownMenuItem -import com.composeunstyled.rememberModalBottomSheetState -import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo -import org.spotube.millennium.theme.MillenniumTheme -import org.spotube.millennium.theme.gelBrush -import org.spotube.millennium.theme.millenniumPanelBrush -import org.spotube.millennium.theme.millenniumSurface import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -69,21 +46,37 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import com.composeunstyled.DropdownMenuPanel +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.UnstyledDropdownMenu +import com.composeunstyled.UnstyledDropdownMenuItem +import com.composeunstyled.rememberModalBottomSheetState import compose.icons.FeatherIcons import compose.icons.feathericons.Check +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.gelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface data class AdaptiveMenuItem( val icon: ImageVector? = null, @@ -106,14 +99,12 @@ fun AdaptiveDropdownBottomSheet( items: List, trigger: @Composable (onClick: () -> Unit) -> Unit, modifier: Modifier = Modifier, - breakpointDp: Float = 600f, menuMinWidth: Dp = 200.dp, header: @Composable (() -> Unit)? = null, headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { var expanded by remember { mutableStateOf(false) } - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium Box(modifier = modifier) { if (isLargeScreen) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 2315074e..7356b4b5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -31,7 +31,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -89,8 +90,8 @@ fun CollectionDetails( showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, sharedElementKey: String? = null) { - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val isCompact = maxWidth < 600.dp + Box(modifier = modifier.fillMaxWidth()) { + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt index 75f09d3a..142358fe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt @@ -41,7 +41,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text -import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -104,8 +105,7 @@ private fun FeaturedCarouselContent( val centerIndex = items.size / 2 var selectedIndex by remember { mutableIntStateOf(centerIndex) } val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index } - val adaptiveInfo = currentWindowAdaptiveInfo() - val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600 + val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium Box( modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt deleted file mode 100644 index 755dc1bd..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.ui.component - - -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumIcon -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape - -import com.composeunstyled.LocalTextStyle -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -/** - * The shared navigation item used by every sidebar in the app (the main app sidebar - * and the settings sidebar), so they stay visually identical. - * - * @param icon leading icon of the item. - * @param badge optional overlay drawn on the icon (for example a download indicator). - */ -@Composable -fun SidebarItem( - label: String, - icon: ImageVector, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - expanded: Boolean = true, - badge: (@Composable BoxScope.() -> Unit)? = null) { - val itemContent: @Composable () -> Unit = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.size(16.dp)) { - MillenniumIcon( - imageVector = icon, - contentDescription = label, - tint = if (selected) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - }) - badge?.invoke(this) - } - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { - Text( - text = label, - singleLine = true, - style = LocalTextStyle.current.copy( - fontSize = AppTheme.typography.labelMedium.fontSize, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - color = if (selected) { - AppTheme.colors.onSecondaryContainer - } else { - AppTheme.colors.onSurfaceVariant - } - ) - ) - } - Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) - } - } - - val buttonModifier = modifier - .fillMaxWidth() - .padding(horizontal = 7.dp, vertical = 2.dp) - - if (selected) { - MillenniumButton( - onClick = onClick, - modifier = buttonModifier, - style = MillenniumButtonStyle.Secondary, - content = itemContent) - } else { - MillenniumButton( - onClick = onClick, - modifier = buttonModifier, - style = MillenniumButtonStyle.Quiet, - content = itemContent) - } -} - -/** - * The shared section heading used above groups of [SidebarItem]s. - */ -@Composable -fun SidebarSectionLabel( - text: String, - expanded: Boolean = true) { - AnimatedVisibility(visible = expanded) { - Text( - text = text.uppercase(), - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp)) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 7fbd1f4b..b15a985a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -18,6 +18,9 @@ package dev.krtirtho.spotube.core.ui.component + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -78,7 +81,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -207,11 +209,10 @@ fun TrackList( } } - val windowInfo = LocalWindowInfo.current - val maxWidth = with(density) { windowInfo.containerSize.width.toDp() } - val isCompact = maxWidth < CompactTrackListBreakpoint + val widthBreakpoint = currentWindowWidthBreakpoint() + val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium val isDesktop = remember { getPlatform().isDesktop() } - val showIndex = maxWidth >= LargeTrackListBreakpoint + val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded val showAlbum = !isCompact val useDropdownForOptions = isDesktop && !isCompact diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index b58b713f..11584429 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.sp import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.millennium.composables.MillenniumTooltipHost import org.spotube.millennium.theme.MillenniumColors import org.spotube.millennium.theme.MillenniumPalette import org.spotube.millennium.theme.MillenniumTheme @@ -75,7 +76,8 @@ data class AppColors( val onError: Color, val errorContainer: Color, val onErrorContainer: Color, - val scrim: Color) + val scrim: Color +) /** Maps Millennium's semantic palette onto the app's role names. */ fun appColorsFrom(m: MillenniumColors): AppColors = AppColors( @@ -106,7 +108,8 @@ fun appColorsFrom(m: MillenniumColors): AppColors = AppColors( onError = m.background, errorContainer = m.danger.copy(alpha = 0.16f), onErrorContainer = m.danger, - scrim = m.scrim) + scrim = m.scrim +) /** Named text styles used by the app, independent of Material typography. */ @Immutable @@ -123,16 +126,19 @@ data class AppTypography( val bodySmall: TextStyle, val labelLarge: TextStyle, val labelMedium: TextStyle, - val labelSmall: TextStyle) + val labelSmall: TextStyle +) private fun style( size: Int, lineHeight: Int, - weight: FontWeight = FontWeight.Normal): TextStyle = TextStyle( + weight: FontWeight = FontWeight.Normal +): TextStyle = TextStyle( fontFamily = FontFamily.SansSerif, fontSize = size.sp, lineHeight = lineHeight.sp, - fontWeight = weight) + fontWeight = weight +) val AppDefaultTypography = AppTypography( displaySmall = style(36, 44), @@ -147,21 +153,25 @@ val AppDefaultTypography = AppTypography( bodySmall = style(12, 16), labelLarge = style(14, 20, FontWeight.Medium), labelMedium = style(12, 16, FontWeight.Medium), - labelSmall = style(11, 16, FontWeight.Medium)) + labelSmall = style(11, 16, FontWeight.Medium) +) /** Corner shapes used by the app. */ @Immutable data class AppShapes( val small: Shape, val medium: Shape, - val large: Shape) + val large: Shape +) val AppDefaultShapes = AppShapes( small = RoundedCornerShape(8.dp), medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp)) + large = RoundedCornerShape(16.dp) +) -val LocalAppColors = staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) } +val LocalAppColors = + staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) } val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography } val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes } @@ -182,7 +192,8 @@ object AppTheme { @Composable fun SpotubeTheme( settings: UserSettings, - content: @Composable () -> Unit) { + content: @Composable () -> Unit +) { val isDarkTheme = when (settings.theme) { Theme.LIGHT -> false Theme.DARK -> true @@ -192,11 +203,14 @@ fun SpotubeTheme( MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) { val colors = appColorsFrom(MillenniumTheme.colors) - CompositionLocalProvider( - LocalAppColors provides colors, - LocalAppTypography provides AppDefaultTypography, - LocalAppShapes provides AppDefaultShapes, - content = content) + MillenniumTooltipHost { + CompositionLocalProvider( + LocalAppColors provides colors, + LocalAppTypography provides AppDefaultTypography, + LocalAppShapes provides AppDefaultShapes, + content = content + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index f274c19e..a7ab61be 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -29,7 +29,8 @@ import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -327,8 +328,8 @@ private fun ArtistHeaderCard( onFollowClick: () -> Unit, isBlacklisted: Boolean, onBlacklistClick: () -> Unit) { - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val isCompact = maxWidth < 600.dp + Box(modifier = Modifier.fillMaxWidth()) { + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium MillenniumCard( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt index 3fc8122c..694bb012 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -42,7 +42,8 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState -import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -74,12 +75,10 @@ fun AddToPlaylistPicker( onDismiss: () -> Unit, onPlaylistSelected: (String) -> Unit, modifier: Modifier = Modifier, - libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f) { + libraryRepository: LibraryRepository = koinInject()) { if (!visible) return - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium var playlists by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index ba7f9e85..7bd7bc80 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -41,7 +41,8 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState -import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable @@ -82,12 +83,10 @@ fun PlaylistFormSheet( onSubmit: (PlaylistFormData) -> Unit, modifier: Modifier = Modifier, initialData: PlaylistFormData? = null, - isEditing: Boolean = false, - breakpointDp: Float = 600f) { + isEditing: Boolean = false) { if (!visible) return - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") } var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index b50e255f..7e59fbc0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -44,7 +44,8 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState -import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -79,12 +80,10 @@ fun AddTracksToPlaylistDialog( onDismiss: () -> Unit, modifier: Modifier = Modifier, searchRepository: SearchRepository = koinInject(), - libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f) { + libraryRepository: LibraryRepository = koinInject()) { if (!visible) return - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium var query by remember { mutableStateOf("") } var searchResults by remember { mutableStateOf>(emptyList()) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index add9858d..9a059b31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -17,8 +17,10 @@ package dev.krtirtho.spotube.modules.settings + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -67,8 +69,8 @@ private val SettingsDialogMinWidth = 720.dp fun SettingsScreen(settingsViewModel: SettingsViewModel) { val navigatorCommands: NavigationCommands = koinInject() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - if (maxWidth >= SettingsDialogMinWidth) { + Box(modifier = Modifier.fillMaxSize()) { + if (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) { SettingsDialog( settingsViewModel = settingsViewModel, onDismiss = { navigatorCommands.pop(Routes.Settings) }) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt index 0104944a..b64109b0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt @@ -23,6 +23,7 @@ import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSidebarItem import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.millenniumPanelBrush import org.spotube.millennium.theme.millenniumSurface @@ -62,7 +63,6 @@ import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.ui.component.SidebarItem import dev.krtirtho.spotube.modules.blacklist.BlacklistContent import dev.krtirtho.spotube.modules.plugin.PluginScreenContent import dev.krtirtho.spotube.modules.settings.sections.appearanceSection @@ -247,11 +247,12 @@ private fun SettingsSidebar( ) } items(tabs, key = { it.name }) { tab -> - SidebarItem( + MillenniumSidebarItem( label = stringResource(tab.titleRes), - icon = tab.icon, + onClick = { onSelect(tab) }, selected = tab == selectedTab, - onClick = { onSelect(tab) }) + contentDescription = stringResource(tab.titleRes), + icon = tab.icon) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index 98cfc5bc..557f5639 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -18,6 +18,11 @@ package dev.krtirtho.spotube.modules.settings.components + + +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.Alignment import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton @@ -27,7 +32,6 @@ import org.spotube.millennium.composables.MillenniumRadioGroup import org.spotube.millennium.composables.MillenniumSwitch import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -92,8 +96,8 @@ internal fun SelectionSettingCard( filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { var isDialogOpen by remember { mutableStateOf(false) } - BoxWithConstraints { - val isWideLayout = maxWidth >= inlineSelectorMinWidth + Box { + val isWideLayout = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium SettingCardItem( title = title, @@ -173,8 +177,8 @@ internal fun TextInputSettingCard( enabled: Boolean = true) { var isDialogOpen by remember { mutableStateOf(false) } - BoxWithConstraints { - val isWideLayout = maxWidth >= inlineTextFieldMinWidth + Box { + val isWideLayout = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium val inlineState = rememberTextFieldState(value) LaunchedEffect(inlineState, isWideLayout) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index 9beb0e79..bdd89d61 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -23,8 +23,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets @@ -32,43 +30,29 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.graphicsLayer - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import org.spotube.millennium.composables.MillenniumSnackbarHost -import org.spotube.millennium.composables.MillenniumSnackbarHostState -import org.spotube.millennium.composables.MillenniumSeparator -import org.spotube.millennium.theme.MillenniumTheme -import com.composeunstyled.Sheet -import com.composeunstyled.SheetDetent -import com.composeunstyled.UnstyledBottomSheet -import com.composeunstyled.rememberBottomSheetState -import com.composeunstyled.Text - +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.composeunstyled.Sheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.UnstyledBottomSheet +import com.composeunstyled.rememberBottomSheetState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator @@ -86,10 +70,13 @@ import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.theme.MillenniumTheme val LocalAppShellBottomInset = staticCompositionLocalOf { 0.dp } -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable fun AppShell( navigator: Navigator, @@ -158,7 +145,6 @@ fun AppShell( Column(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.weight(1f)) { AppSidebar( - hazeState = bgHazeState, navigator = navigator, navigationState = navigationState ) @@ -214,7 +200,7 @@ fun AppShell( } } } else { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() @@ -278,7 +264,7 @@ fun AppShell( @OptIn( ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable -private fun BoxWithConstraintsScope.CompactPlayerOverlay( +private fun CompactPlayerOverlay( navigator: Navigator, navigationState: NavigationState, viewModel: AppShellViewModel, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt index 4139fe18..1e8fd536 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt @@ -17,9 +17,10 @@ package dev.krtirtho.spotube.modules.shell + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import androidx.lifecycle.ViewModel import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -63,9 +64,7 @@ class AppShellViewModel : ViewModel() { if (maxWidth != null) { return maxWidth >= sidebarMinWidth } - val density = LocalDensity.current - val windowInfo = LocalWindowInfo.current - return with(density) { windowInfo.containerSize.width.toDp() >= sidebarMinWidth } + return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded } fun bottomOverlayInset(useSidebar: Boolean): Dp = diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index e63f2507..1d28b09e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -22,7 +22,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -35,11 +34,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.composeunstyled.Text -import dev.chrisbanes.haze.HazeState import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator @@ -67,7 +66,6 @@ import spotube.composeapp.generated.resources.cookie_regular @Composable fun AppSidebar( - hazeState: HazeState, navigator: Navigator, navigationState: NavigationState, modifier: Modifier = Modifier, @@ -80,6 +78,7 @@ fun AppSidebar( MillenniumSidebar( modifier = modifier, collapsed = !expanded, + shape = RectangleShape, onCollapsedChange = { expanded = !it }, ) { MillenniumSidebarHeader { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt index d949602b..6162b809 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt @@ -17,7 +17,11 @@ package dev.krtirtho.spotube.modules.shell.alternative_track -import androidx.compose.foundation.layout.BoxWithConstraints + + +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -32,8 +36,8 @@ fun AlternativeTrackSheet( onDismiss: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit) { - BoxWithConstraints(modifier = modifier) { - if (maxWidth >= SlidingSheetBreakpoint) { + Box(modifier = modifier) { + if (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) { EndDrawerSheet(isVisible, onDismiss, content) } else { BottomSheetContent(isVisible, onDismiss, content) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 33d9a4d2..f845a2d1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -18,18 +18,8 @@ package dev.krtirtho.spotube.modules.shell.player_queue -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumCard -import org.spotube.millennium.composables.MillenniumDropdownMenu -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumListTile -import org.spotube.millennium.composables.MillenniumSeparator -import org.spotube.millennium.composables.MillenniumTextField -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,14 +35,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.shape.CircleShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme - -import com.composeunstyled.Text +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -60,29 +46,47 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.composeunstyled.Text import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTooltip import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.queue_clear_queue +import spotube.composeapp.generated.resources.queue_filter_queue +import spotube.composeapp.generated.resources.queue_no_queue_entries +import spotube.composeapp.generated.resources.queue_title @Composable fun PlayerQueueContent( viewModel: PlayerQueueContentViewModel = koinViewModel(), - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { val state by viewModel.queueContentUiState.collectAsState() val displayItems = state.displayItems val filterQuery = state.filterQuery @@ -103,14 +107,17 @@ fun PlayerQueueContent( modifier = Modifier .fillMaxSize() .padding(horizontal = 8.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(10.dp)) { + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { Text( - text = "Queue", - style = AppTheme.typography.titleLarge) + text = stringResource(Res.string.queue_title), + style = AppTheme.typography.titleLarge + ) Row( horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { val filterState = rememberTextFieldState(filterQuery) LaunchedEffect(filterState.text) { viewModel.setQueueFilter(filterState.text.toString()) @@ -118,42 +125,49 @@ fun PlayerQueueContent( MillenniumTextField( state = filterState, label = "", - placeholder = "Filter queue...", - modifier = Modifier.weight(1f)) + placeholder = stringResource(Res.string.queue_filter_queue), + modifier = Modifier.weight(1f) + ) if (!isReadOnly) { - MillenniumIconButton( - onClick = viewModel::clearQueue, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + MillenniumTooltip(stringResource(Res.string.queue_clear_queue)) { + MillenniumIconButton( + onClick = viewModel::clearQueue, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxTrash, + contentDescription = stringResource(Res.string.queue_clear_queue) + ) + } } } } if (displayItems.isEmpty()) { Text( - text = "No queue entries", + text = stringResource(Res.string.queue_no_queue_entries), style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } else { - MillenniumCard { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = lazyListState, - contentPadding = PaddingValues(bottom = 8.dp)) { - items(displayItems, key = { it.id }) { item -> - ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> - val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) - QueueItemRow( - item = item, - reorderScope = if (isFiltered || isReadOnly) null else this, - onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, - onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, - onDragStarted = { viewModel.onDragStart() }, - onDragStopped = { viewModel.onDragStop() }, - showOptions = !isReadOnly, - enabled = !isReadOnly, - onParticipantClick = { selectedParticipant = it }) - } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(bottom = 8.dp) + ) { + items(displayItems, key = { it.id }) { item -> + ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> + QueueItemRow( + item = item, + reorderScope = if (isFiltered || isReadOnly) null else this, + onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, + onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, + onDragStarted = { viewModel.onDragStart() }, + onDragStopped = { viewModel.onDragStop() }, + showOptions = !isReadOnly, + enabled = !isReadOnly, + onParticipantClick = { selectedParticipant = it }) } } } @@ -179,32 +193,38 @@ private fun ParticipantDialog( onDismiss: () -> Unit, onKick: () -> Unit, onBan: () -> Unit, - onRemoveSuggestions: () -> Unit) { + onRemoveSuggestions: () -> Unit +) { AdaptiveDialogBottomSheet( onDismiss = onDismiss, title = { Text(participant.displayName, style = AppTheme.typography.titleLarge) }) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp)) { + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.padding(vertical = 8.dp)) { + modifier = Modifier.padding(vertical = 8.dp) + ) { ParticipantAvatar(participant, size = 40) Text( text = participant.displayName, - style = AppTheme.typography.bodyLarge) + style = AppTheme.typography.bodyLarge + ) if (participant.isHost) { Text( text = "Host", style = AppTheme.typography.labelSmall, - color = AppTheme.colors.primary) + color = AppTheme.colors.primary + ) } } if (isJamHost && !participant.isHost) { MillenniumSeparator( - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) MillenniumListTile( onClick = { onKick() @@ -214,7 +234,8 @@ private fun ParticipantDialog( MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, contentDescription = null, - tint = AppTheme.colors.error) + tint = AppTheme.colors.error + ) }, title = { Text("Kick") }, subtitle = { Text("Remove them from the session") }) @@ -227,7 +248,8 @@ private fun ParticipantDialog( MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = null, - tint = AppTheme.colors.error) + tint = AppTheme.colors.error + ) }, title = { Text("Ban") }, subtitle = { Text("Kick and prevent them from rejoining") }) @@ -239,7 +261,8 @@ private fun ParticipantDialog( leading = { MillenniumIcon( imageVector = Iconsax.IconsaxMusicSquareRemove, - contentDescription = null) + contentDescription = null + ) }, title = { Text("Remove suggestions") }, subtitle = { Text("Remove every track they added to the queue") }) @@ -255,11 +278,13 @@ private fun ParticipantAvatar(participant: JamParticipant, size: Int) { .size(size.dp) .clip(CircleShape) .background(AppTheme.colors.primaryContainer), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { Text( text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", style = AppTheme.typography.labelMedium, - color = AppTheme.colors.onPrimaryContainer) + color = AppTheme.colors.onPrimaryContainer + ) } } @@ -273,7 +298,8 @@ private fun QueueItemRow( onDragStopped: () -> Unit, showOptions: Boolean = true, enabled: Boolean = true, - onParticipantClick: (JamParticipant) -> Unit = {}) { + onParticipantClick: (JamParticipant) -> Unit = {} +) { var showMenu by remember { mutableStateOf(false) } MillenniumListTile( @@ -284,8 +310,9 @@ private fun QueueItemRow( leading = { Row( modifier = Modifier - .height(72.dp), - verticalAlignment = Alignment.CenterVertically) { + .height(56.dp), + verticalAlignment = Alignment.CenterVertically + ) { MillenniumIcon( Iconsax.IconsaxDragHandle, contentDescription = if (reorderScope != null) "Reorder" else null, @@ -296,7 +323,8 @@ private fun QueueItemRow( with(reorderScope) { Modifier.draggableHandle( onDragStarted = { onDragStarted() }, - onDragStopped = onDragStopped) + onDragStopped = onDragStopped + ) } } else { Modifier @@ -305,21 +333,24 @@ private fun QueueItemRow( Box( modifier = Modifier - .size(48.dp) + .size(40.dp) .clip(AppTheme.shapes.small) .background(AppTheme.colors.surfaceVariant), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { if (item.imageUrl != null) { AsyncImage( model = item.imageUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) } else { Text( text = "${item.originalIndex + 1}", style = AppTheme.typography.labelMedium, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } } } @@ -334,7 +365,8 @@ private fun QueueItemRow( AppTheme.colors.onSecondaryContainer } else { AppTheme.colors.onSurface - }) + } + ) }, subtitle = { Text( @@ -342,13 +374,15 @@ private fun QueueItemRow( style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) }, trailing = { Text( text = item.durationLabel, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) Spacer(modifier = Modifier.width(4.dp)) @@ -359,11 +393,13 @@ private fun QueueItemRow( .clip(CircleShape) .background(AppTheme.colors.surfaceVariant) .clickable { onParticipantClick(participant) }, - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { Text( text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } Spacer(modifier = Modifier.width(4.dp)) } @@ -380,10 +416,12 @@ private fun QueueItemRow( anchor = { MillenniumIconButton( onClick = { showMenu = true }, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( Iconsax.Iconsax3DotsMore, - contentDescription = "More options") + contentDescription = "More options" + ) } }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index 7bd75267..6175b72a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -17,15 +17,17 @@ package dev.krtirtho.spotube.modules.shell.player_queue -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import com.composeunstyled.DrawerPlacement import com.composeunstyled.SheetDetent +import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.composables.MillenniumDrawer import org.spotube.millennium.composables.MillenniumDrawerValue import org.spotube.millennium.composables.MillenniumModalBottomSheet @@ -38,13 +40,14 @@ fun QueueSheet( isVisible: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, - content: @Composable () -> Unit) { - BoxWithConstraints(modifier = modifier) { - if (maxWidth >= SlidingSheetBreakpoint) { - EndDrawerSheet(isVisible, onDismiss, content) - } else { - BottomSheetContent(isVisible, onDismiss, content) - } + content: @Composable () -> Unit +) { + val isExpanded = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded + + if (isExpanded) { + EndDrawerSheet(isVisible, onDismiss, content) + } else { + BottomSheetContent(isVisible, onDismiss, content) } } @@ -52,7 +55,8 @@ fun QueueSheet( internal fun EndDrawerSheet( isVisible: Boolean, onDismiss: () -> Unit, - content: @Composable () -> Unit) { + content: @Composable () -> Unit +) { val drawerState = rememberMillenniumDrawerState() LaunchedEffect(isVisible) { drawerState.targetValue = @@ -66,7 +70,10 @@ internal fun EndDrawerSheet( MillenniumDrawer( state = drawerState, placement = DrawerPlacement.End, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) { + shape = RectangleShape, + maximumWidth = 400.dp, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + ) { content() } } @@ -75,7 +82,8 @@ internal fun EndDrawerSheet( internal fun BottomSheetContent( isVisible: Boolean, onDismiss: () -> Unit, - content: @Composable () -> Unit) { + content: @Composable () -> Unit +) { if (!isVisible) return val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) LaunchedEffect(sheetState.bottomSheetState.currentDetent) { @@ -85,7 +93,8 @@ internal fun BottomSheetContent( } MillenniumModalBottomSheet( state = sheetState, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) + ) { content() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt index 80b6b4eb..c88cff74 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -1,6 +1,11 @@ package dev.krtirtho.spotube.modules.welcome.pages + + +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.composables.MillenniumCard import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton @@ -38,8 +43,8 @@ fun HowItWorksPage( onTogglePlayback: () -> Unit, modifier: Modifier = Modifier ) { - BoxWithConstraints(modifier.fillMaxSize()) { - val wide = maxWidth >= 760.dp + Box(modifier.fillMaxSize()) { + val wide = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded if (wide) { Row( Modifier.fillMaxSize().padding(40.dp), From 76518a01a1f3ae98bb4f3a8a1d0fa4403cfd4040 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 13 Oct 2026 13:03:27 +0600 Subject: [PATCH 039/132] feat(onboarding): enhance welcome screen with structured guide steps and improved layout --- .../composeResources/values/strings.xml | 23 +- .../krtirtho/spotube/core/ui/misc/Shimmer.kt | 5 +- .../spotube/modules/plugin/PluginScreen.kt | 35 +-- .../spotube/modules/welcome/WelcomeContent.kt | 11 + .../spotube/modules/welcome/WelcomeModels.kt | 3 +- .../spotube/modules/welcome/WelcomeScreen.kt | 217 +++++++--------- .../modules/welcome/WelcomeViewModel.kt | 26 +- .../spotube/modules/welcome/WelcomeVisuals.kt | 12 +- .../modules/welcome/pages/HowItWorksPage.kt | 241 ++++++------------ .../ui/component/ApplicationMainBar.jvm.kt | 12 +- 10 files changed, 237 insertions(+), 348 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 01b95038..2b2e1674 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -246,7 +246,7 @@ Spotube Music, without the strings. An open-source player for a world of music.\nYours to explore. Yours to make your own. - Keep music free. + Keep this project alive. Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software. Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here. Discover plugins @@ -255,12 +255,21 @@ More plugins No new plugins found. You can also install one from a link or a .smplug file above. Plugin discovery is unavailable right now. Try again, or install from a link or file. - A familiar player. A different kind of freedom. - Browse through your chosen catalog. Press play and your audio plugin finds the stream.\n\nYour device handles playback and caching. Add synced lyrics, save tracks for later, or listen together with Group Jam. - DEMONSTRATION - PAUSED - Pause demonstration - Play demonstration + Welcome + Support + Plugins + Guide + + How to use Spotube + Four quick steps and you’re listening. + Sign in + Connect your Spotify account, or pick your own catalog plugin. + Search and play + Find any song, album, or artist, then press play. + Listen offline + Download tracks to keep playing without a connection. + Follow the lyrics + Open synced lyrics from the player while a track plays. Your music. Your sources. Your rules. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt index 9cbd742f..39795062 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt @@ -107,9 +107,12 @@ fun TextWithShimmer( minLines: Int = 1, onTextLayout: ((TextLayoutResult) -> Unit)? = null, style: TextStyle = LocalTextStyle.current) { + // While a skeleton tree is loading, the placeholder glyphs must not be readable: + // only the shimmer bars (from `shimmerApply`) should show through. + val isLoadingSkeleton = LocalSkeletonLoading.current Text( text = text, - color = color, + color = if (isLoadingSkeleton) Color.Transparent else color, modifier = Modifier.shimmerApply().then(modifier), autoSize = autoSize, fontSize = fontSize, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index a578caf8..2942c39a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -26,6 +26,9 @@ import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.millenniumPanelBrush @@ -285,29 +288,19 @@ internal fun PluginScreenContent( } } item { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(bottom = 12.dp) - ) { - MillenniumButton( + MillenniumSegmentedButtonRow( + modifier = Modifier.padding(bottom = 12.dp)) { + MillenniumSegmentedButton( + selected = data.onboardingDiscover, onClick = { viewModel.showOnboardingDiscovery(true) }, - style = MillenniumButtonStyle.Secondary) { - Text( - stringResource(Res.string.welcome_plugins_discover), - fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal - ) + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { + Text(stringResource(Res.string.welcome_plugins_discover)) } - MillenniumButton( - onClick = { - viewModel.showOnboardingDiscovery( - false - ) - }, - style = MillenniumButtonStyle.Secondary) { - Text( - stringResource(Res.string.welcome_plugins_sources), - fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal - ) + MillenniumSegmentedButton( + selected = !data.onboardingDiscover, + onClick = { viewModel.showOnboardingDiscovery(false) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { + Text(stringResource(Res.string.welcome_plugins_sources)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt index 76e07713..bf36e128 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt @@ -32,6 +32,10 @@ import spotube.composeapp.generated.resources.welcome_donate_patreon_title import spotube.composeapp.generated.resources.welcome_get_started import spotube.composeapp.generated.resources.welcome_lets_go import spotube.composeapp.generated.resources.welcome_next +import spotube.composeapp.generated.resources.welcome_step_guide +import spotube.composeapp.generated.resources.welcome_step_plugins +import spotube.composeapp.generated.resources.welcome_step_support +import spotube.composeapp.generated.resources.welcome_step_welcome /** Order matches the 4 onboarding pages. */ internal val welcomePrimaryLabels: List = listOf( @@ -40,6 +44,13 @@ internal val welcomePrimaryLabels: List = listOf( Res.string.welcome_next, Res.string.welcome_get_started) +/** Short labels for the step switcher. Order matches the 4 onboarding pages. */ +internal val welcomeStepLabels: List = listOf( + Res.string.welcome_step_welcome, + Res.string.welcome_step_support, + Res.string.welcome_step_plugins, + Res.string.welcome_step_guide) + internal val welcomeDonations: List = listOf( DonationOption( id = "patreon", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt index e5a293b6..c41925d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt @@ -33,5 +33,4 @@ data class WelcomeUiState( val pageCount: Int, val isLastPage: Boolean, val primaryLabel: StringResource, - val donations: List, - val demoPlaying: Boolean = true) + val donations: List) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt index 8eaa84c0..0bb19540 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt @@ -20,28 +20,22 @@ package dev.krtirtho.spotube.modules.welcome import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard import org.spotube.millennium.composables.MillenniumIcon -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTabGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text @@ -49,7 +43,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -63,13 +56,15 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.welcome_back import spotube.composeapp.generated.resources.welcome_skip /** - * First-run Welcome/Onboarding screen. It is a route ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) - * rendered inside the app shell. Uses the application's base controls and keeps plugin setup - * in-place, with a neutral palette independent of the user's chosen accent. + * First-run Welcome/Onboarding screen. It is a route + * ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell. + * + * The steps are exposed through a Millennium tab group rather than ad-hoc dots or a footer + * walk-through, so the screen reads as one deliberate panel on every window size. + * Each step owns its content and the footer only carries the forward action. */ @Composable fun WelcomeScreen(viewModel: WelcomeViewModel) { @@ -96,99 +91,31 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { } } }) - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - Column( + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + contentAlignment = Alignment.Center) { + MillenniumCard( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - // Central stage (animated page content) - Box( - modifier = Modifier - .weight(1f) - .widthIn(max = 1280.dp) - .fillMaxWidth(), - contentAlignment = Alignment.Center) { - AnimatedContent( - modifier = Modifier.fillMaxSize(), - targetState = state.currentPageIndex, - transitionSpec = { - if (targetState > initialState) { - (slideInHorizontally { it / 4 } + fadeIn()) togetherWith - (slideOutHorizontally { -it / 4 } + fadeOut()) - } else { - (slideInHorizontally { -it / 4 } + fadeIn()) togetherWith - (slideOutHorizontally { it / 4 } + fadeOut()) - } - }, - label = "welcome_page") { index -> - when (index) { - 0 -> BrandingPage() - - 1 -> DonationPage( - donations = state.donations, - onDonate = viewModel::openDonation) - - 2 -> PluginsPage() - - else -> HowItWorksPage( - isPlaying = state.demoPlaying, - onTogglePlayback = viewModel::toggleDemo - ) - } - } - } - - Spacer(Modifier.height(10.dp)) - - // Bottom precision navigation bar: Back | Page Dots | Action Button - Row( - modifier = Modifier - .widthIn(max = 680.dp) - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically) { - // Left: Back button - if (state.currentPageIndex > 0) { - MillenniumButton( - onClick = viewModel::previousPage, - style = MillenniumButtonStyle.Secondary) { - Text( - text = stringResource(Res.string.welcome_back), - color = AppTheme.colors.onSurface) - } - } else { - Spacer(Modifier.width(76.dp)) - } - - // Center: Precision page dots - Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - PageIndicator( - pageCount = state.pageCount, - currentPageIndex = state.currentPageIndex) - } - - // Right: Primary Next / Let's Go action - MillenniumButton( - onClick = viewModel::onPrimary, - style = MillenniumButtonStyle.Secondary) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = stringResource(state.primaryLabel), - fontWeight = FontWeight.SemiBold) - MillenniumIcon( - imageVector = if (state.isLastPage) { - Iconsax.IconsaxCheckCircle - } else { - Iconsax.IconsaxNext - }, - contentDescription = null, - modifier = Modifier.size(16.dp)) - } - } + .widthIn(max = 1040.dp) + .fillMaxWidth() + .fillMaxHeight(), + contentPadding = PaddingValues(0.dp)) { + Column(Modifier.fillMaxSize()) { + WelcomeStepSwitcher( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + state = state, + viewModel = viewModel) + MillenniumSeparator() + WelcomeActionBar( + state = state, + viewModel = viewModel) } } } @@ -198,31 +125,59 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { } @Composable -private fun PageIndicator( - pageCount: Int, - currentPageIndex: Int, +private fun WelcomeStepSwitcher( + state: WelcomeUiState, + viewModel: WelcomeViewModel, modifier: Modifier = Modifier) { - Row( - modifier = modifier - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalAlignment = Alignment.CenterVertically) { - repeat(pageCount) { index -> - val selected = index == currentPageIndex - val width by animateDpAsState( - targetValue = if (selected) 20.dp else 6.dp, - label = "welcome_page_indicator") - Box( - modifier = Modifier - .size(width = width, height = 6.dp) - .clip(CircleShape) - .background( - if (selected) { - AppTheme.colors.onSurface - } else { - AppTheme.colors.outlineVariant - } - )) + val labels = welcomeStepLabels.map { stringResource(it) } + MillenniumTabGroup( + selectedTab = state.currentPageIndex, + onSelectedTabChange = viewModel::goToPage, + tabs = labels.indices.toList(), + modifier = modifier, + tabLabel = { labels[it] }) { index -> + when (index) { + 0 -> BrandingPage() + + 1 -> DonationPage( + donations = state.donations, + onDonate = viewModel::openDonation) + + 2 -> PluginsPage() + + else -> HowItWorksPage() + } + } +} + +@Composable +private fun WelcomeActionBar( + state: WelcomeUiState, + viewModel: WelcomeViewModel) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.weight(1f)) + MillenniumButton( + onClick = viewModel::onPrimary, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(state.primaryLabel), + fontWeight = FontWeight.SemiBold) + MillenniumIcon( + imageVector = if (state.isLastPage) { + Iconsax.IconsaxCheckCircle + } else { + Iconsax.IconsaxNext + }, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt index 296c2d11..a2868fb8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -30,18 +30,16 @@ import kotlinx.coroutines.launch class WelcomeViewModel( private val repository: WelcomeRepository) : ViewModel() { private val currentPageIndex = MutableStateFlow(0) - private val demoPlaying = MutableStateFlow(true) val uiState: StateFlow = - combine(currentPageIndex, demoPlaying) { index, playing -> + currentPageIndex.map { index -> val pageCount = welcomePrimaryLabels.size WelcomeUiState( currentPageIndex = index, pageCount = pageCount, isLastPage = index == pageCount - 1, primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], - donations = welcomeDonations, - demoPlaying = playing) + donations = welcomeDonations) } .stateIn( scope = viewModelScope, @@ -53,16 +51,16 @@ class WelcomeViewModel( primaryLabel = welcomePrimaryLabels.first(), donations = welcomeDonations)) - fun nextPage() { - currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) } - } - - fun toggleDemo() { - demoPlaying.update { !it } + fun goToPage(index: Int) { + currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) } } fun onPrimary() { - if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage() + if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) { + completeOnboarding() + } else { + goToPage(currentPageIndex.value + 1) + } } fun openDonation(url: String) { @@ -71,10 +69,6 @@ class WelcomeViewModel( runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) } } - fun previousPage() { - currentPageIndex.update { (it - 1).coerceAtLeast(0) } - } - fun completeOnboarding() { viewModelScope.launch { repository.completeOnboarding() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt index cf815c99..5b1d99f4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt @@ -33,13 +33,17 @@ import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.theme.MillenniumTheme import spotube.composeapp.generated.resources.* -/** Installs the Millennium theme for the onboarding pages. */ +/** + * Reapplies the Millennium theme for the onboarding pages using the app's active configuration, + * so onboarding matches the palette and appearance used by the rest of the app. + */ @Composable fun WelcomeTheme(content: @Composable () -> Unit) { val dark = AppTheme.colors.surface.luminance() < 0.5f - MillenniumTheme(dark = dark) { - content() - } + MillenniumTheme( + dark = dark, + config = MillenniumTheme.config, + content = content) } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt index c88cff74..f9453f16 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -1,187 +1,98 @@ package dev.krtirtho.spotube.modules.welcome.pages - - - -import androidx.compose.foundation.layout.Box -import com.composeunstyled.currentWindowWidthBreakpoint -import org.spotube.millennium.adaptive.WindowBreakpoints -import org.spotube.millennium.composables.MillenniumCard import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import androidx.compose.animation.core.* -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import org.spotube.millennium.composables.MillenniumListTile +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.* +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch +import dev.krtirtho.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import spotube.composeapp.generated.resources.* -import kotlin.math.PI -import kotlin.math.sin +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_guide_lyrics_desc +import spotube.composeapp.generated.resources.welcome_guide_lyrics_title +import spotube.composeapp.generated.resources.welcome_guide_offline_desc +import spotube.composeapp.generated.resources.welcome_guide_offline_title +import spotube.composeapp.generated.resources.welcome_guide_search_desc +import spotube.composeapp.generated.resources.welcome_guide_search_title +import spotube.composeapp.generated.resources.welcome_guide_signin_desc +import spotube.composeapp.generated.resources.welcome_guide_signin_title +import spotube.composeapp.generated.resources.welcome_guide_subtitle +import spotube.composeapp.generated.resources.welcome_guide_title +/** + * The onboarding guide: a short, concrete answer to "how do I actually use this app?". + * No marketing prose — just the four things a new user needs to do. + */ @Composable -fun HowItWorksPage( - isPlaying: Boolean, - onTogglePlayback: () -> Unit, - modifier: Modifier = Modifier -) { - Box(modifier.fillMaxSize()) { - val wide = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded - if (wide) { - Row( - Modifier.fillMaxSize().padding(40.dp), - horizontalArrangement = Arrangement.spacedBy(48.dp), - verticalAlignment = Alignment.CenterVertically) { - PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f)) - ListeningIntroduction(Modifier.weight(1f)) - } - } else { - Column( - Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)) { - ListeningIntroduction() - PlayerDemonstration(isPlaying, onTogglePlayback) - } +fun HowItWorksPage(modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_guide_title), + style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif)) + Text( + stringResource(Res.string.welcome_guide_subtitle), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant) + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WelcomeGuideStep( + icon = Iconsax.User, + title = Res.string.welcome_guide_signin_title, + description = Res.string.welcome_guide_signin_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxSearch, + title = Res.string.welcome_guide_search_title, + description = Res.string.welcome_guide_search_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxDocumentDownload, + title = Res.string.welcome_guide_offline_title, + description = Res.string.welcome_guide_offline_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxMusic, + title = Res.string.welcome_guide_lyrics_title, + description = Res.string.welcome_guide_lyrics_desc) } } } @Composable -private fun ListeningIntroduction(modifier: Modifier = Modifier) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { - Text( - stringResource(Res.string.welcome_how_quiet_title), - style = AppTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif)) - Text( - stringResource(Res.string.welcome_how_quiet_description), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant) - } -} - -/** Silent, illustrative player. The ViewModel owns the play/pause interaction. */ -@Composable -private fun PlayerDemonstration( - isPlaying: Boolean, - onTogglePlayback: () -> Unit, - modifier: Modifier = Modifier +private fun WelcomeGuideStep( + icon: ImageVector, + title: StringResource, + description: StringResource, ) { - val motion = rememberInfiniteTransition(label = "welcome_record") - val rotation by motion.animateFloat( - 0f, 360f, - infiniteRepeatable(tween(12000, easing = LinearEasing)), label = "record_rotation" - ) - val phase by motion.animateFloat( - 0f, (2 * PI).toFloat(), - infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter" - ) - val scheme = AppTheme.colors - - MillenniumCard(modifier = modifier.fillMaxWidth()) { - Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { - Text( - stringResource(if (isPlaying) Res.string.welcome_demo_streaming else Res.string.welcome_demo_paused), - style = AppTheme.typography.labelSmall, - color = scheme.onSurfaceVariant) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp) - ) { - Canvas(Modifier.size(104.dp).rotate(if (isPlaying) rotation else 0f)) { - val radius = size.minDimension / 2f - drawCircle(Color(0xFF171717)) - for (groove in 1..8) { - drawCircle( - Color(0xFF343434), - radius * (0.4f + groove * 0.065f), - style = Stroke(1f) - ) - } - drawCircle(Color(0xFF334A6B), radius * 0.32f) - drawCircle(Color(0xFFE1DDD5), radius * 0.045f) - // An off-centre label mark makes the turntable's motion visible. - drawCircle( - Color(0xFFD6CEC0), - radius * 0.025f, - Offset(center.x, center.y - radius * 0.2f) - ) - } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - stringResource(Res.string.welcome_how_mockup_title), - style = AppTheme.typography.titleLarge - ) - Text( - stringResource(Res.string.welcome_how_mockup_artist), - style = AppTheme.typography.bodySmall, color = scheme.onSurfaceVariant - ) - Canvas(Modifier.fillMaxWidth().height(32.dp)) { - val count = 18 - val barWidth = size.width / (count * 1.7f) - repeat(count) { index -> - val wave = if (isPlaying) (sin(phase + index * 0.6f) + 1f) / 2f else 0f - val height = size.height * (0.12f + wave * 0.88f) - drawRect( - scheme.primary, - Offset(index * size.width / count, size.height - height), - androidx.compose.ui.geometry.Size(barWidth, height) - ) - } - } - } - } - Text( - stringResource(Res.string.welcome_how_mockup_lyric), - style = AppTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif), - color = scheme.onSurfaceVariant - ) - Row( - Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - MillenniumIcon( - Iconsax.IconsaxPrevious, - null, - Modifier.size(18.dp), - tint = scheme.onSurfaceVariant - ) - Spacer(Modifier.width(24.dp)) - MillenniumIconButton( - onClick = onTogglePlayback, - modifier = Modifier.size(48.dp), - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - stringResource(if (isPlaying) Res.string.welcome_demo_pause_action else Res.string.welcome_demo_play_action), - Modifier.size(24.dp) - ) - } - Spacer(Modifier.width(24.dp)) - MillenniumIcon( - Iconsax.IconsaxNext, - null, - Modifier.size(18.dp), - tint = scheme.onSurfaceVariant - ) - } - } - } + MillenniumListTile( + leading = { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary) + }, + title = { Text(stringResource(title)) }, + subtitle = { Text(stringResource(description)) }) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index e5012afe..34f67902 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -25,10 +25,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.graphics.SolidColor import androidx.compose.foundation.window.WindowDraggableArea import org.spotube.millennium.composables.MillenniumTopAppBar @@ -53,6 +56,8 @@ import dev.nucleusframework.window.WindowDoubleClickAction import dev.nucleusframework.window.windowDragArea import org.koin.compose.koinInject +private val WindowControlsReservedWidth = 132.dp + @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), @@ -68,6 +73,9 @@ actual fun ApplicationMainBar( title = title, actions = { actions() + // Reserve room for the platform window controls instead of shrinking the + // whole bar, so it spans the full width with no seam under the controls. + Spacer(Modifier.width(WindowControlsReservedWidth)) }, navigationIcon = { if (backButton) { @@ -75,7 +83,9 @@ actual fun ApplicationMainBar( } }, subtitle = subtitle, - modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons + background = if (transparent) SolidColor(Color.Transparent) else null, + separatorColor = if (transparent) Color.Transparent else null, + modifier = Modifier.fillMaxWidth() ) LocalWindowScope.current.WindowControls( modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp), From 8efcbd4af40aadcee17e57adbf85b936c49d1386 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 13 Oct 2026 13:06:22 +0600 Subject: [PATCH 040/132] feat(player): add content descriptions for play, pause, and add to queue actions --- .../composeResources/values/strings.xml | 3 +++ .../core/ui/component/cards/PlayableCard.kt | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2b2e1674..2ceb48c1 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -230,6 +230,9 @@ Go to album Sleep timer Track details + Play + Pause + Add to queue Ignore Update diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt index 74ccc375..a4674b27 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt @@ -62,6 +62,11 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_add_to_queue_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -107,14 +112,16 @@ fun PlayableCard( ) { val isHovered by interactionSource.collectIsHoveredAsState() this@Column.AnimatedVisibility( - visible = isHovered, + visible = isHovered || (isPlaying && onPlay != null), enter = fadeIn(), exit = fadeOut()) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { - onAddToQueue?.let { + // While this collection is playing the play/pause toggle stays + // visible and the add-to-queue action is hidden entirely. + onAddToQueue?.takeIf { !isPlaying }?.let { MillenniumIconButton( onClick = onAddToQueue, modifier = Modifier.size(34.dp), @@ -122,10 +129,11 @@ fun PlayableCard( ) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue") + contentDescription = stringResource( + Res.string.player_add_to_queue_content_description)) } + Spacer(modifier = Modifier.height(8.dp)) } - Spacer(modifier = Modifier.height(8.dp)) onPlay?.let { MillenniumIconButton( onClick = onPlay, @@ -137,7 +145,12 @@ fun PlayableCard( imageVector = if (isPlaying) { Iconsax.IconsaxPause } else Iconsax.IconsaxPlay, - contentDescription = "Play") + contentDescription = stringResource( + if (isPlaying) { + Res.string.player_pause_content_description + } else { + Res.string.player_play_content_description + })) } } } From d720467338617c20201a4a9911bdda984670912f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 09:51:07 +0600 Subject: [PATCH 041/132] feat(support): implement support reminder dialog and donation options --- .../composeResources/values/strings.xml | 10 ++ .../kotlin/dev/krtirtho/spotube/App.kt | 131 ++++++++++----- .../dev/krtirtho/spotube/core/DebugConfig.kt | 11 +- .../spotube/modules/home/HomeScreen.kt | 145 ++++++++++++----- .../modules/settings/SettingsModels.kt | 6 +- .../modules/settings/SettingsScreen.kt | 4 + .../modules/settings/SettingsSidebarLayout.kt | 10 ++ .../components/InteractiveSettingCards.kt | 5 +- .../settings/sections/SupportSection.kt | 76 +++++++++ .../spotube/modules/shell/AppSidebar.kt | 15 +- .../spotube/modules/support/Support.kt | 151 ++++++++++++++++++ .../spotube/modules/welcome/WelcomeContent.kt | 30 ---- .../spotube/modules/welcome/WelcomeModels.kt | 10 +- .../modules/welcome/WelcomeViewModel.kt | 7 +- .../modules/welcome/pages/DonationPage.kt | 2 +- 15 files changed, 485 insertions(+), 128 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2ceb48c1..8aee91ce 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -31,6 +31,7 @@ Caching Desktop Updates + Support About Close @@ -301,6 +302,15 @@ Sponsor Open %1$s + + Support Spotube + Support Spotube + Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project. + You can turn these reminders off in Settings → Support. + Maybe later + Support reminders + Show a gentle reminder to support the project every 7 days. + Modular Architecture Choose where your music comes from. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 1ab1f561..0cf66c03 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -24,12 +24,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay +import dev.krtirtho.spotube.core.DebugConfig import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES @@ -41,6 +45,9 @@ import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.shell.AppShell +import dev.krtirtho.spotube.modules.support.LocalSupportPrompt +import dev.krtirtho.spotube.modules.support.SupportDialog +import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis import dev.krtirtho.spotube.modules.update.AppUpdateDialog import dev.krtirtho.spotube.modules.update.AppUpdateService import dev.krtirtho.spotube.modules.update.PluginUpdateDialog @@ -59,6 +66,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.time.Clock import org.koin.compose.koinInject import org.koin.compose.navigation3.koinEntryProvider import org.koin.core.annotation.KoinExperimentalAPI @@ -138,6 +146,40 @@ fun App( val welcomeRepository: WelcomeRepository = koinInject() val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle() + var showSupportDialog by remember { mutableStateOf(false) } + var supportDialogIsReminder by remember { mutableStateOf(false) } + + // Nudge the user to support the project at most once every seven days, unless they opted + // out in Settings. The first eligible launch after onboarding only records the baseline, so + // the prompt never appears right after the onboarding donation page. + LaunchedEffect(showOnboarding) { + if (showOnboarding) return@LaunchedEffect + // Read the persisted value rather than the collected state, whose initial value is the + // default (0) until DataStore has emitted once. + val current = settingsRepository.getCurrentSettings() + val now = Clock.System.now().toEpochMilliseconds() + // Development override: show the reminder on every launch, regardless of timing or the + // "Support reminders" toggle. + if (DebugConfig.FORCE_SUPPORT_REMINDER) { + supportDialogIsReminder = true + showSupportDialog = true + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + return@LaunchedEffect + } + if (!current.supportRemindersEnabled) return@LaunchedEffect + val last = current.lastSupportReminderEpochMs + if (last == 0L) { + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + } else if (now - last >= SupportReminderIntervalMillis) { + supportDialogIsReminder = true + showSupportDialog = true + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + } + } + // The app always renders the shell + NavDisplay; onboarding is just the start destination. // When it is completed the destination flips to Home and the navigation state is rebuilt. val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home @@ -150,52 +192,61 @@ fun App( } SpotubeTheme(settings = userSettings) { - Box(modifier = Modifier.fillMaxSize()) { - val currentRoute = - navigationState.backStacks[navigationState.topLevelRoute]?.last() + CompositionLocalProvider(LocalSupportPrompt provides { + supportDialogIsReminder = false + showSupportDialog = true + }) { + Box(modifier = Modifier.fillMaxSize()) { + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() - Box(Modifier.fillMaxSize()) { - AppShell(navigator, navigationState) { - SharedTransitionLayout { - CompositionLocalProvider( - LocalSharedTransitionScope provides this@SharedTransitionLayout - ) { - Column { - NavDisplay( - modifier = Modifier.fillMaxSize(), - onBack = navigator::pop, - entries = navigationState.toEntries(koinEntryProvider()) - ) + Box(Modifier.fillMaxSize()) { + AppShell(navigator, navigationState) { + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout + ) { + Column { + NavDisplay( + modifier = Modifier.fillMaxSize(), + onBack = navigator::pop, + entries = navigationState.toEntries(koinEntryProvider()) + ) + } } } } - } - when (currentRoute) { - Routes.WebView -> WebViewScreen(koinInject()) - else -> {} + when (currentRoute) { + Routes.WebView -> WebViewScreen(koinInject()) + else -> {} + } } } - } - content() - availableAppUpdate?.let { update -> - AppUpdateDialog( - update = update, - onDismiss = appUpdateService::dismissUpdate, - onIgnore = appUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } - appUpdateService.dismissUpdate() - }) - } - availablePluginUpdate?.let { update -> - PluginUpdateDialog( - update = update, - onDismiss = pluginUpdateService::dismissUpdate, - onIgnore = pluginUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser(update.info.directDownloadUrl) } - pluginUpdateService.dismissUpdate() - }) + content() + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + appUpdateService.dismissUpdate() + }) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser(update.info.directDownloadUrl) } + pluginUpdateService.dismissUpdate() + }) + } + SupportDialog( + visible = showSupportDialog, + onDismissRequest = { showSupportDialog = false }, + isReminder = supportDialogIsReminder) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt index 249315bf..5be13cfb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt @@ -29,5 +29,14 @@ object DebugConfig { * * Keep it `false` when committing. */ - const val FORCE_SHOW_ONBOARDING: Boolean = true + const val FORCE_SHOW_ONBOARDING: Boolean = false + + /** + * Flip to `true` while developing to show the support reminder dialog on the next eligible + * app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle. + * It still waits until onboarding is dismissed, and only fires once per launch. + * + * Keep it `false` when committing. + */ + const val FORCE_SUPPORT_REMINDER: Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index 3ab5a299..4cd088ed 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -50,11 +50,13 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.currentWindowWidthBreakpoint import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection @@ -75,21 +77,26 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.modules.support.LocalSupportPrompt import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.support_button_label import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import org.jetbrains.compose.resources.Font +import org.spotube.millennium.adaptive.WindowBreakpoints +import spotube.composeapp.generated.resources.cookie_regular @Composable fun HomeScreen(viewModel: HomeScreenViewModel) { val navigationCommands = koinInject() - val platform = getPlatform() - val isDesktop = platform.type == PlatformType.Windows || - platform.type == PlatformType.Linux || - platform.type == PlatformType.MacOS - + val openSupport = LocalSupportPrompt.current + val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() @@ -124,27 +131,55 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { Column( modifier = Modifier .fillMaxSize() - .background(MillenniumTheme.colors.background)) { + .background(MillenniumTheme.colors.background) + ) { ApplicationMainBar( backButton = false, title = { - Text("Browse") + if (isSmallScreen) { + Text( + "Spotube", + style = AppTheme.typography.headlineLarge.copy( + fontFamily = FontFamily( + listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal)) + ) + ), + color = AppTheme.colors.onSurface + ) + } else { + Text("Browse") + } + }, actions = { - if (!isDesktop) { + if (isSmallScreen) { MillenniumIconButton( onClick = { navigationCommands.navigateTo(Routes.Devices) }, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = "Devices") + contentDescription = "Devices" + ) } MillenniumIconButton( onClick = { navigationCommands.navigateTo(Routes.Jam) }, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.User, - contentDescription = "Group Jam") + contentDescription = "Group Jam" + ) + } + val supportLabel = stringResource(Res.string.support_button_label) + MillenniumIconButton( + onClick = openSupport, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxHeart2, + contentDescription = supportLabel + ) } } }) @@ -153,7 +188,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { .fillMaxSize() .weight(1f) ) { - if (isDesktop) { + if (!isSmallScreen) { HomeContent( listState = listState, state = state, @@ -165,7 +200,8 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { onRefresh = { viewModel.refresh() }, - modifier = Modifier.fillMaxSize()) { + modifier = Modifier.fillMaxSize() + ) { HomeContent( listState = listState, state = state, @@ -188,7 +224,8 @@ private fun HomeContent( listState: androidx.compose.foundation.lazy.LazyListState, state: HomeScreenState, onRetry: () -> Unit, - onGenreSelected: (String) -> Unit) { + onGenreSelected: (String) -> Unit +) { val shellBottomInset = LocalAppShellBottomInset.current val contentPadding = remember(shellBottomInset) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) @@ -198,31 +235,37 @@ private fun HomeContent( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(24.dp)) { + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { when (state) { is HomeScreenState.Loading -> { item { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp)) { + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { repeat(3) { SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp)) { + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp)) + .padding(horizontal = 16.dp) + ) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp)) { + contentPadding = PaddingValues(horizontal = 16.dp) + ) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -236,7 +279,8 @@ private fun HomeContent( item { ErrorDisplay( errorMessage = state.message, - onRetry = onRetry) + onRetry = onRetry + ) } } @@ -252,7 +296,8 @@ private fun HomeContent( GenreTabs( genres = state.genres, selectedGenreId = state.selectedGenreId, - onGenreSelected = onGenreSelected) + onGenreSelected = onGenreSelected + ) } } @@ -263,20 +308,24 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp)) { + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp)) + .padding(horizontal = 16.dp) + ) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp)) { + contentPadding = PaddingValues(horizontal = 16.dp) + ) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -287,7 +336,8 @@ private fun HomeContent( HomeSection( title = section.title, subtitle = section.description, - items = section.items) + items = section.items + ) } } @@ -296,20 +346,24 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp)) { + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp)) + .padding(horizontal = 16.dp) + ) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp)) { + contentPadding = PaddingValues(horizontal = 16.dp) + ) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -327,30 +381,36 @@ private fun HomeSection( title: String, subtitle: String? = null, items: List, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { val rowState = rememberLazyListState() Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp)) { + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { if (!subtitle.isNullOrEmpty()) Text( text = subtitle, style = AppTheme.typography.labelMedium.copy( color = AppTheme.colors.secondary, - fontWeight = FontWeight.Medium), - modifier = Modifier.padding(horizontal = 16.dp)) + fontWeight = FontWeight.Medium + ), + modifier = Modifier.padding(horizontal = 16.dp) + ) Text( text = title, style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp)) + modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp) + ) LazyRow( state = rowState, modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top) { + verticalAlignment = Alignment.Top + ) { items(items) { browseItem -> when (browseItem) { is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data) @@ -368,14 +428,16 @@ private fun HomeSection( private fun GenreTabs( genres: List, selectedGenreId: String?, - onGenreSelected: (String) -> Unit) { + onGenreSelected: (String) -> Unit +) { val rowState = rememberLazyListState() LazyRow( state = rowState, modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp)) { + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { items(genres) { genre -> MillenniumButton( onClick = { onGenreSelected(genre.id) }, @@ -383,7 +445,8 @@ private fun GenreTabs( MillenniumButtonStyle.Primary } else { MillenniumButtonStyle.Secondary - }) { + } + ) { Text(genre.name) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index b3dc1c60..8d01591d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -87,7 +87,11 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, val ignoredAppUpdateVersion: String? = null, - val ignoredPluginUpdateVersions: Map = emptyMap()) + val ignoredPluginUpdateVersions: Map = emptyMap(), + + // Support + val supportRemindersEnabled: Boolean = true, + val lastSupportReminderEpochMs: Long = 0L) /** * Configuration for the MQTT broker used by Group Jam. The host is a placeholder diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 9a059b31..68b23df1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -50,6 +50,7 @@ import dev.krtirtho.spotube.modules.settings.sections.jamSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.pluginsSection +import dev.krtirtho.spotube.modules.settings.sections.supportSection import dev.krtirtho.spotube.modules.settings.sections.updatesSection import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import org.jetbrains.compose.resources.stringResource @@ -154,6 +155,9 @@ private fun CompactSettingsContent( settings = settings, settingsViewModel = settingsViewModel) } + supportSection( + settings = settings, + settingsViewModel = settingsViewModel) updatesSection( settings = settings, settingsViewModel = settingsViewModel) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt index b64109b0..ec1c5649 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt @@ -71,6 +71,7 @@ import dev.krtirtho.spotube.modules.settings.sections.desktopSection import dev.krtirtho.spotube.modules.settings.sections.downloadsSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection +import dev.krtirtho.spotube.modules.settings.sections.supportSection import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle import dev.krtirtho.spotube.modules.settings.sections.updatesSection import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset @@ -81,6 +82,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden +import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2 import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare @@ -102,6 +104,7 @@ import spotube.composeapp.generated.resources.settings_section_jam import spotube.composeapp.generated.resources.settings_section_language_region import spotube.composeapp.generated.resources.settings_section_playback import spotube.composeapp.generated.resources.settings_section_plugins +import spotube.composeapp.generated.resources.settings_section_support private val SettingsSidebarWidth = 236.dp @@ -123,6 +126,7 @@ internal enum class SettingsTab( Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload), Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive), Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), + Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2), About(Res.string.settings_section_about, Iconsax.IconsaxInformation), } @@ -317,6 +321,12 @@ private fun SettingsTabContent( SettingsTab.Desktop -> desktopSection(settings, settingsViewModel, includeHeader = false) + SettingsTab.Support -> + supportSection( + settings = settings, + settingsViewModel = settingsViewModel, + includeHeader = false) + SettingsTab.About -> updatesSection(settings, settingsViewModel, includeHeader = false) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index 557f5639..58fe3c3c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -74,7 +74,10 @@ internal fun SwitchSettingCard( MillenniumSwitch( checked = checked, onCheckedChange = onCheckedChange, - label = title) + // The row itself already renders the title/subtitle, so the switch must not + // draw a second visible label next to it. + label = "", + contentSpacing = 0.dp) }, onClick = { onCheckedChange(!checked) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt new file mode 100644 index 00000000..d1482005 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt @@ -0,0 +1,76 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.components.SettingCardItem +import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard +import dev.krtirtho.spotube.modules.support.donationOptions +import dev.krtirtho.spotube.modules.support.openDonation +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_section_support +import spotube.composeapp.generated.resources.settings_support_reminders_subtitle +import spotube.composeapp.generated.resources.settings_support_reminders_title + +/** Support settings: the periodic reminder toggle plus the donation destinations. */ +internal fun LazyListScope.supportSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_support) + } + settingsSectionCard( + items = buildList { + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_support_reminders_title), + subtitle = stringResource(Res.string.settings_support_reminders_subtitle), + icon = { + SettingsItemIcon( + imageVector = Iconsax.IconsaxHeart2, + contentDescription = stringResource( + Res.string.settings_support_reminders_title)) + }, + checked = settings.supportRemindersEnabled, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(supportRemindersEnabled = enabled) + } + }) + } + donationOptions.forEach { option -> + add { + SettingCardItem( + title = stringResource(option.title), + subtitle = stringResource(option.subtitle), + icon = { + SettingsItemIcon( + imageVector = option.icon, + contentDescription = stringResource(option.title)) + }, + onClick = { openDonation(option) }) + } + } + }) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 1d28b09e..2a5d0ed8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -46,10 +46,13 @@ import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryTab +import dev.krtirtho.spotube.modules.support.LocalSupportPrompt import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.sidebarTabs import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.spotube.millennium.composables.MillenniumSidebar import org.spotube.millennium.composables.MillenniumSidebarContent @@ -63,6 +66,8 @@ import org.spotube.millennium.composables.MillenniumSidebarMenu import org.spotube.millennium.composables.MillenniumSidebarTrigger import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.cookie_regular +import spotube.composeapp.generated.resources.settings_screen_title +import spotube.composeapp.generated.resources.support_button_label @Composable fun AppSidebar( @@ -74,6 +79,7 @@ fun AppSidebar( var expanded by rememberSaveable { mutableStateOf(true) } val groupExpansion = remember { mutableStateMapOf() } val currentLibraryTab by libraryState.currentTab.collectAsState() + val openSupport = LocalSupportPrompt.current MillenniumSidebar( modifier = modifier, @@ -143,10 +149,17 @@ fun AppSidebar( } } MillenniumSidebarFooter { + val supportLabel = stringResource(Res.string.support_button_label) MillenniumSidebarItem( - label = "Settings", + label = supportLabel, + onClick = { openSupport() }, + contentDescription = supportLabel, + icon = Iconsax.IconsaxHeart2) + MillenniumSidebarItem( + label = stringResource(Res.string.settings_screen_title), onClick = { navigator.navigate(Routes.Settings) }, selected = navigationState.topLevelRoute == Routes.Settings, + contentDescription = stringResource(Res.string.settings_screen_title), icon = Iconsax.IconsaxSetting2) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt new file mode 100644 index 00000000..fc1c59a8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt @@ -0,0 +1,151 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.support + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import dev.krtirtho.spotube.openUrlInBrowser +import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee +import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective +import dev.krtirtho.spotube.resources.iconsax.BrandPatreon +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.support_dialog_dismiss +import spotube.composeapp.generated.resources.support_dialog_message +import spotube.composeapp.generated.resources.support_dialog_reminder_hint +import spotube.composeapp.generated.resources.support_dialog_title +import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle +import spotube.composeapp.generated.resources.welcome_donate_bmac_title +import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle +import spotube.composeapp.generated.resources.welcome_donate_opencollective_title +import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle +import spotube.composeapp.generated.resources.welcome_donate_patreon_title + +/** A single donation destination offered across the app. */ +data class DonationOption( + val id: String, + val title: StringResource, + val subtitle: StringResource, + val url: String, + val icon: ImageVector) + +/** The donation destinations shown in onboarding, settings and the support prompt. */ +val donationOptions: List = listOf( + DonationOption( + id = "patreon", + title = Res.string.welcome_donate_patreon_title, + subtitle = Res.string.welcome_donate_patreon_subtitle, + url = "https://patreon.com/krtirtho", + icon = Iconsax.BrandPatreon), + DonationOption( + id = "buymeacoffee", + title = Res.string.welcome_donate_bmac_title, + subtitle = Res.string.welcome_donate_bmac_subtitle, + url = "https://www.buymeacoffee.com/krtirtho", + icon = Iconsax.BrandBuyMeACoffee), + DonationOption( + id = "opencollective", + title = Res.string.welcome_donate_opencollective_title, + subtitle = Res.string.welcome_donate_opencollective_subtitle, + url = "https://opencollective.com/spotube", + icon = Iconsax.BrandOpencollective)) + +/** Minimum time between two automatic support reminders. */ +const val SupportReminderIntervalMillis: Long = 7L * 24 * 60 * 60 * 1000 + +/** + * Opens the support prompt from anywhere inside the app shell. Hosted at the app root so the + * sidebar, home top bar and the periodic reminder all drive the same dialog. + */ +val LocalSupportPrompt = staticCompositionLocalOf { {} } + +/** Opens a donation destination in the user's browser. */ +fun openDonation(option: DonationOption) { + runCatching { openUrlInBrowser(option.url) } +} + +/** A dialog that lets the user pick a way to support the project. */ +@Composable +fun SupportDialog( + visible: Boolean, + onDismissRequest: () -> Unit, + isReminder: Boolean = false) { + if (!visible) return + MillenniumDialog( + visible = true, + onDismissRequest = onDismissRequest, + title = stringResource(Res.string.support_dialog_title)) { + Text( + stringResource(Res.string.support_dialog_message), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + if (isReminder) { + Text( + stringResource(Res.string.support_dialog_reminder_hint), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + donationOptions.forEach { option -> + MillenniumButton( + onClick = { + openDonation(option) + onDismissRequest() + }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = option.icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (option.id == "patreon") { + AppTheme.colors.onSurface + } else { + Color.Unspecified + }) + Text(stringResource(option.title)) + } + } + } + MillenniumButton( + onClick = onDismissRequest, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.support_dialog_dismiss)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt index bf36e128..b40a78da 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt @@ -17,18 +17,8 @@ package dev.krtirtho.spotube.modules.welcome -import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee -import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective -import dev.krtirtho.spotube.resources.iconsax.BrandPatreon -import dev.krtirtho.spotube.resources.iconsax.Iconsax import org.jetbrains.compose.resources.StringResource import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle -import spotube.composeapp.generated.resources.welcome_donate_bmac_title -import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle -import spotube.composeapp.generated.resources.welcome_donate_opencollective_title -import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle -import spotube.composeapp.generated.resources.welcome_donate_patreon_title import spotube.composeapp.generated.resources.welcome_get_started import spotube.composeapp.generated.resources.welcome_lets_go import spotube.composeapp.generated.resources.welcome_next @@ -50,23 +40,3 @@ internal val welcomeStepLabels: List = listOf( Res.string.welcome_step_support, Res.string.welcome_step_plugins, Res.string.welcome_step_guide) - -internal val welcomeDonations: List = listOf( - DonationOption( - id = "patreon", - title = Res.string.welcome_donate_patreon_title, - subtitle = Res.string.welcome_donate_patreon_subtitle, - url = "https://patreon.com/krtirtho", - icon = Iconsax.BrandPatreon), - DonationOption( - id = "buymeacoffee", - title = Res.string.welcome_donate_bmac_title, - subtitle = Res.string.welcome_donate_bmac_subtitle, - url = "https://www.buymeacoffee.com/krtirtho", - icon = Iconsax.BrandBuyMeACoffee), - DonationOption( - id = "opencollective", - title = Res.string.welcome_donate_opencollective_title, - subtitle = Res.string.welcome_donate_opencollective_subtitle, - url = "https://opencollective.com/spotube", - icon = Iconsax.BrandOpencollective)) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt index c41925d5..34d7be18 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt @@ -17,17 +17,9 @@ package dev.krtirtho.spotube.modules.welcome -import androidx.compose.ui.graphics.vector.ImageVector +import dev.krtirtho.spotube.modules.support.DonationOption import org.jetbrains.compose.resources.StringResource -/** A single donation destination shown on the support page. */ -data class DonationOption( - val id: String, - val title: StringResource, - val subtitle: StringResource, - val url: String, - val icon: ImageVector) - data class WelcomeUiState( val currentPageIndex: Int, val pageCount: Int, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt index a2868fb8..02120e8f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt @@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.welcome import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.modules.support.donationOptions import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -39,7 +40,7 @@ class WelcomeViewModel( pageCount = pageCount, isLastPage = index == pageCount - 1, primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], - donations = welcomeDonations) + donations = donationOptions) } .stateIn( scope = viewModelScope, @@ -49,7 +50,7 @@ class WelcomeViewModel( pageCount = welcomePrimaryLabels.size, isLastPage = welcomePrimaryLabels.size == 1, primaryLabel = welcomePrimaryLabels.first(), - donations = welcomeDonations)) + donations = donationOptions)) fun goToPage(index: Int) { currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) } @@ -65,7 +66,7 @@ class WelcomeViewModel( fun openDonation(url: String) { // Only destinations owned by our static funding list can be opened here. - if (welcomeDonations.none { it.url == url }) return + if (donationOptions.none { it.url == url }) return runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt index 8663aa17..30f516f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.modules.welcome.DonationOption +import dev.krtirtho.spotube.modules.support.DonationOption import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* From 93dfea9a309b46a47d21a9458a196029e5e2f3e1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 13:53:19 +0600 Subject: [PATCH 042/132] feat(player): refactor artist display and improve search field labels --- .../library/album/LibraryAlbumsScreen.kt | 13 +-- .../library/artist/LibraryArtistsScreen.kt | 2 +- .../local_tracks/LibraryLocalTracksScreen.kt | 2 +- .../library/playlist/AddToPlaylistPicker.kt | 2 +- .../playlist/LibraryPlaylistsScreen.kt | 2 +- .../playlist/AddTracksToPlaylistDialog.kt | 1 - .../spotube/modules/search/SearchScreen.kt | 2 +- .../modules/shell/AppExpandedPlayer.kt | 9 +- .../spotube/modules/shell/AppLargePlayer.kt | 17 ++-- .../spotube/modules/shell/AppPlayerState.kt | 26 ++++-- .../modules/shell/PlayerArtistsText.kt | 82 +++++++++++++++++++ .../dev/krtirtho/spotube/Platform.jvm.kt | 21 ++++- 12 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index c9d0832c..ed4b7a00 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -93,12 +93,13 @@ fun LibraryAlbumsScreen( } Column(modifier = modifier) { - AnimatedVisibility(searchMode){ + AnimatedVisibility(searchMode) { MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.fillMaxWidth(), - placeholder = "Search saved albums...") + placeholder = "Search saved albums..." + ) } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -119,7 +120,8 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -168,7 +170,8 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index 9056f51d..eadb5bce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -95,7 +95,7 @@ fun LibraryArtistsScreen( AnimatedVisibility(searchMode){ MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.fillMaxWidth(), placeholder = "Search saved artists...") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 288df6e3..31ce23ce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -106,7 +106,7 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean contentDescription = "Search") MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.weight(1f), placeholder = if (state.currentFolderPath == null) { "Search folders or tracks" diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt index 694bb012..d5908f90 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -129,7 +129,7 @@ fun AddToPlaylistPicker( contentDescription = "Search") MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.weight(1f), placeholder = "Search your playlists...") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 779e1df8..45247b53 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -110,7 +110,7 @@ fun LibraryPlaylistsScreen( modifier = Modifier.weight(1f)) { MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.fillMaxWidth(), placeholder = "Search saved playlists...") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index 7e59fbc0..ef9d8a74 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -143,7 +143,6 @@ fun AddTracksToPlaylistDialog( verticalAlignment = Alignment.CenterVertically) { MillenniumTextField( state = queryState, - label = "Search", modifier = Modifier.weight(1f), placeholder = "Search tracks...") MillenniumIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index a3900f93..f4709cb0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -478,7 +478,7 @@ private fun SearchBar( .padding(bottom = 8.dp)) { MillenniumAutocomplete( state = searchState, - label = "Search", + suggestions = suggestions, onSuggestionSelected = { entry -> entry.onClick() }, suggestionLabel = { entry -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index df23acf1..228141aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -481,12 +481,13 @@ fun AppExpandedPlayer( maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) - Text( - text = playerUiState.artists, + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + }, style = AppTheme.typography.bodyLarge, color = AppTheme.colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) } MillenniumIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index fd274a6e..e2a87728 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -252,19 +252,24 @@ fun AppLargePlayer( contentScale = ContentScale.Crop ) } - Column(modifier = Modifier.padding(start = 12.dp)) { + Column( + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 12.dp) + ) { Text( text = playerUiState.title, style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = playerUiState.artists, + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + }, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis + color = AppTheme.colors.onSurfaceVariant ) } Spacer(modifier = Modifier.width(10.dp)) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt index c05c604c..06fcf1ef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt @@ -20,21 +20,25 @@ package dev.krtirtho.spotube.modules.shell import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.rememberLogger import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +internal data class PlayerArtist( + val id: String?, + val name: String +) + internal data class PlayerUiState( val queue: List = emptyList(), val currentQueueEntry: QueueEntry? = null, val title: String = "No-op track title", val artists: String = "No-op artists", + val artistList: List = emptyList(), val album: String = "", val coverUrl: String = "", val position: Duration = 0.milliseconds, @@ -43,7 +47,8 @@ internal data class PlayerUiState( val playerState: PlayerState = PlayerState.IDLE, val loopState: LoopState = LoopState.NONE, val isShuffling: Boolean = false, - val volume: Float = 0f) { + val volume: Float = 0f +) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val displayDuration: Duration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration @@ -73,7 +78,8 @@ internal data class PlayerUiState( @Composable internal fun rememberPlayerUiState( audioPlayer: AudioPlayerInterface, - audioPlayerQueue: AudioPlayerQueue): PlayerUiState { + audioPlayerQueue: AudioPlayerQueue +): PlayerUiState { val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null) val playerState by audioPlayer.playerStateFlow.collectAsState() @@ -89,6 +95,7 @@ internal fun rememberPlayerUiState( currentQueueEntry = currentQueueEntry, title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" }, artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" }, + artistList = currentQueueEntry?.displayArtistList().orEmpty(), album = currentQueueEntry?.displayAlbum().orEmpty(), coverUrl = currentQueueEntry?.coverUrl().orEmpty(), position = position, @@ -97,7 +104,8 @@ internal fun rememberPlayerUiState( playerState = playerState, loopState = loopState, isShuffling = isShuffling, - volume = volume) + volume = volume + ) // val logger = rememberLogger() // logger.d { state.toDebugString() } @@ -118,6 +126,14 @@ private fun QueueEntry.displayArtists(): String { } } +private fun QueueEntry.displayArtistList(): List { + return when (this) { + is QueueEntry.StreamingTrack -> track.artists.map { PlayerArtist(it.id, it.name) } + // Local tracks have no artist ids, so they are shown but not navigable. + is QueueEntry.LocalTrack -> artists.map { PlayerArtist(id = null, name = it) } + } +} + private fun QueueEntry.displayAlbum(): String { return when (this) { is QueueEntry.StreamingTrack -> track.album?.title ?: "Unknown Album" diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt new file mode 100644 index 00000000..588c00fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt @@ -0,0 +1,82 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.shell + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +/** + * Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id] + * are tappable links that open their artist page; local tracks (no ids) render as plain text. + */ +@Composable +internal fun PlayerArtistsText( + artists: List, + onArtistClick: (String) -> Unit, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Unspecified) { + if (artists.isEmpty()) return + val hoverColor = AppTheme.colors.primary + val annotatedArtists = buildAnnotatedString { + artists.forEachIndexed { index, artist -> + if (index > 0) append(", ") + val artistId = artist.id + if (artistId == null) { + append(artist.name) + } else { + withLink( + LinkAnnotation.Clickable( + tag = artistId, + styles = TextLinkStyles( + style = SpanStyle(textDecoration = TextDecoration.Underline), + hoveredStyle = SpanStyle( + color = hoverColor, + textDecoration = TextDecoration.Underline), + ), + linkInteractionListener = { onArtistClick(artistId) }, + ) + ) { + append(artist.name) + } + } + } + } + Text( + text = annotatedArtists, + modifier = modifier, + style = style, + color = color, + textAlign = textAlign, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt index dba09a14..b816c283 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt @@ -39,5 +39,24 @@ class JVMPlatform : Platform { actual fun getPlatform(): Platform = JVMPlatform() actual fun openUrlInBrowser(url: String) { - Desktop.getDesktop().browse(URI(url)) + // The app runs on a native (Tao) window, not an AWT frame, so java.awt.Desktop is not a + // reliable way to hand a URL to the OS. Prefer the platform's own opener process and only + // fall back to AWT when no opener is available on PATH. + if (openWithSystemOpener(url)) return + runCatching { Desktop.getDesktop().browse(URI(url)) } +} + +private fun openWithSystemOpener(url: String): Boolean { + val osName = System.getProperty("os.name").lowercase() + val command = when { + osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", url) + osName.contains("mac") -> listOf("open", url) + osName.contains("nux") || osName.contains("nix") || osName.contains("aix") -> + listOf("xdg-open", url) + else -> return false + } + return runCatching { + ProcessBuilder(command).start() + true + }.getOrDefault(false) } \ No newline at end of file From 1ccd70a18ce4f6b32451c1f5e160c0f3460ad285 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 16:29:45 +0600 Subject: [PATCH 043/132] feat(search): enhance search functionality with recent search suggestions and improved UI components --- .../composeResources/values/strings.xml | 6 + .../component/AdaptiveDropdownBottomSheet.kt | 261 ++++--------- .../spotube/core/ui/theming/AppTheme.kt | 29 +- .../spotube/modules/search/SearchScreen.kt | 361 ++++++++++-------- .../modules/search/SearchScreenViewModel.kt | 9 +- .../spotube/modules/shell/AppSidebar.kt | 32 +- 6 files changed, 336 insertions(+), 362 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 8aee91ce..32787564 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -21,6 +21,12 @@ Filter queue... No queue entries + Search songs, artists, albums... + Clear all history + Search... + + Selected + Settings Plugins diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index e8646ed2..5c7ec032 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -19,23 +19,17 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -45,19 +39,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.vector.ImageVector import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.composeunstyled.DropdownMenuPanel import com.composeunstyled.SheetDetent -import com.composeunstyled.Text -import com.composeunstyled.UnstyledDropdownMenu -import com.composeunstyled.UnstyledDropdownMenuItem import com.composeunstyled.rememberModalBottomSheetState import compose.icons.FeatherIcons import compose.icons.feathericons.Check @@ -67,16 +55,19 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText import org.spotube.millennium.composables.MillenniumTextField -import org.spotube.millennium.theme.MillenniumTheme -import org.spotube.millennium.theme.gelBrush -import org.spotube.millennium.theme.millenniumPanelBrush -import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.accessibility_selected +import spotube.composeapp.generated.resources.search_filter_placeholder data class AdaptiveMenuItem( val icon: ImageVector? = null, @@ -108,118 +99,17 @@ fun AdaptiveDropdownBottomSheet( Box(modifier = modifier) { if (isLargeScreen) { - UnstyledDropdownMenu( + MillenniumDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, - panel = { - DropdownMenuPanel( - Modifier - .width(menuMinWidth) - .millenniumSurface( - background = millenniumPanelBrush(MillenniumTheme.colors), - shape = AppTheme.shapes.medium, - borderColor = AppTheme.colors.outlineVariant, - borderWidth = 1.dp) - .padding(4.dp)) { - if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { - header() - } - - val dropdownQueryState = rememberTextFieldState() - val query = dropdownQueryState.text.toString() - if (filter != null) { - MillenniumTextField( - state = dropdownQueryState, - label = "", - placeholder = "Search...", - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp)) - } - - items.forEach { item -> - if (query.isNotBlank() && filter != null && !filter(item, query)) { - return@forEach - } - if (item.dividerBefore) { - MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) - } - val itemInteraction = remember { MutableInteractionSource() } - val itemHovered by itemInteraction.collectIsHoveredAsState() - val itemPressed by itemInteraction.collectIsPressedAsState() - val itemEnabled = item.enabled - val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected) - val itemForeground = when { - !itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f) - itemHighlighted -> MillenniumTheme.colors.onAccent - else -> AppTheme.colors.onSurface - } - UnstyledDropdownMenuItem( - onClick = { item.onClick() }, - enabled = itemEnabled, - indication = null, - interactionSource = itemInteraction) { - Row( - modifier = Modifier - .fillMaxWidth() - .then( - if (itemHighlighted) { - Modifier.millenniumSurface( - background = gelBrush( - MillenniumTheme.colors.accentTop, - MillenniumTheme.colors.accentBottom), - shape = RoundedCornerShape(8.dp)) - } else { - Modifier - }) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp)) { - val itemIcon = item.icon - when { - item.leadingContent != null -> item.leadingContent.invoke() - itemIcon != null -> MillenniumIcon( - imageVector = itemIcon, - contentDescription = null, - tint = itemForeground, - modifier = Modifier.size(20.dp)) - - item.selected -> MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - tint = itemForeground, - modifier = Modifier.size(20.dp)) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.label, - style = AppTheme.typography.bodyMedium, - color = itemForeground) - item.subtitle?.let { subtitle -> - Text( - text = subtitle, - style = AppTheme.typography.bodySmall, - color = if (itemHighlighted) { - itemForeground.copy(alpha = 0.85f) - } else { - AppTheme.colors.onSurfaceVariant - }) - } - } - if (item.selected && (item.icon != null || item.leadingContent != null)) { - MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - tint = itemForeground, - modifier = Modifier.size(20.dp)) - } - } - } - } - } - }, - anchor = { trigger { expanded = true } }, - ) + items = items.map { it.label }, + onItemClick = { label -> + items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke() + expanded = false + }, + minimumWidth = menuMinWidth, + anchor = { trigger { expanded = true } } + ) } else { trigger { expanded = true } if (expanded) { @@ -267,7 +157,7 @@ private fun AdaptiveBottomSheetContent( MillenniumTextField( state = queryState, label = "", - placeholder = "Search...", + placeholder = stringResource(Res.string.search_filter_placeholder), modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp)) @@ -285,72 +175,69 @@ private fun AdaptiveBottomSheetContent( if (item.dividerBefore) { MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(AppTheme.shapes.medium) - .clickable( - enabled = item.enabled, - onClick = { - item.onClick() - onDismiss() - }) - .background( - color = if (item.selected) { - AppTheme.colors.secondary.copy(alpha = 0.12f) - } else { - Color.Transparent - }, - shape = AppTheme.shapes.medium) - .padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp)) { - when { - item.leadingContent != null -> item.leadingContent.invoke() - item.icon != null -> MillenniumIcon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = if (item.enabled) { - AppTheme.colors.onSurface - } else { - AppTheme.colors.onSurface.copy(alpha = 0.38f) - }) - - item.selected -> MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(22.dp), - tint = AppTheme.colors.onSurfaceVariant) - - hasSelection -> Spacer(modifier = Modifier.size(22.dp)) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.label, + MillenniumListTile( + title = { + MillenniumText( + item.label, style = AppTheme.typography.bodyLarge, color = if (item.enabled) { AppTheme.colors.onSurface } else { AppTheme.colors.onSurface.copy(alpha = 0.38f) - }) - item.subtitle?.let { subtitle -> - Text( - text = subtitle, + } + ) + }, + subtitle = item.subtitle?.let { subtitle -> + { + MillenniumText( + subtitle, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } - } - - if (item.selected && (item.icon != null || item.leadingContent != null)) { - MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(22.dp), - tint = AppTheme.colors.onSurfaceVariant) - } - } + }, + leading = when { + item.leadingContent != null -> item.leadingContent + item.icon != null -> { + { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + } + } + item.selected -> { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp) + ) + } + } + hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }} + else -> null + }, + trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp) + ) + } + } else { + null + }, + selected = item.selected, + enabled = item.enabled, + onClick = { + item.onClick() + onDismiss() + }, + minimumHeight = 56.dp + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index 11584429..b20a2ede 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -36,7 +36,12 @@ import androidx.compose.ui.unit.sp import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings import org.spotube.millennium.composables.MillenniumTooltipHost +import org.spotube.millennium.composables.MillenniumTopAppBarTheme +import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties +import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme import org.spotube.millennium.theme.MillenniumColors +import org.spotube.millennium.theme.MillenniumDarkThemeComponents +import org.spotube.millennium.theme.MillenniumLightThemeComponents import org.spotube.millennium.theme.MillenniumPalette import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumThemeConfig @@ -201,7 +206,29 @@ fun SpotubeTheme( } val palette: MillenniumPalette = settings.palette - MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) { + val darkTheme = MillenniumDarkThemeComponents + val lightTheme = MillenniumLightThemeComponents + + MillenniumTheme( + dark = isDarkTheme, + config = MillenniumThemeConfig( + palette = palette, + dark = darkTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = darkTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ) + ), + light = lightTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = lightTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ) + ) + ) + ) { val colors = appColorsFrom(MillenniumTheme.colors) MillenniumTooltipHost { CompositionLocalProvider( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index f4709cb0..ff720d6c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -19,13 +19,12 @@ package dev.krtirtho.spotube.modules.search import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumText import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedContent @@ -78,8 +77,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import compose.icons.FeatherIcons -import compose.icons.feathericons.X import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist @@ -115,6 +112,10 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.InconsaxClock +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_clear_history +import spotube.composeapp.generated.resources.search_placeholder import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -145,8 +146,12 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } var currentUserId by remember { mutableStateOf(null) } - val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList()) - val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList()) + val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle( + emptyList() + ) + val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle( + emptyList() + ) fun isTrackBlacklisted(track: MetadataTrack): Boolean { val trackIds = blacklistedTracks.map { it.id }.toSet() @@ -172,7 +177,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")), autoPlay = true, startPosition = 0, - collectionEntry = null) + collectionEntry = null + ) } } @@ -188,7 +194,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { if (existing != null) { // Already in the local queue: move it to the next position audioPlayerQueue.removeFromQueue(existing) - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + audioPlayerQueue.addAllAfterCurrent( + listOf( + QueueEntry.StreamingTrack( + track = track, + url = "" + ) + ) + ) } else { remotePlaybackController.requestTrackPlayNext(track) } @@ -197,6 +210,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.AddToQueue -> { remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { remotePlaybackController.addTrackToJam(track) } @@ -217,6 +231,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { shareService.share(uri, track.title) } } + is TrackOptionsAction.AddToPlaylist -> { tracksToAddToPlaylist = listOf(track) showAddToPlaylistPicker = true @@ -236,7 +251,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track)) + isBlacklisted = isTrackBlacklisted(track) + ) } val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList()) @@ -251,7 +267,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track)) + isBlacklisted = isTrackBlacklisted(track) + ) } fun bulkAddToQueue(tracks: List) { @@ -269,7 +286,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { Column( modifier = Modifier .fillMaxSize() - .background(MillenniumTheme.colors.background)) { + .background(MillenniumTheme.colors.background) + ) { ApplicationMainBar(backButton = false) Column( modifier = Modifier @@ -282,12 +300,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { SearchBar( query = state.query, onQueryChange = viewModel::onQueryChange, - recentSearches = state.recentSearches, + recentSearchSuggestions = state.recentSearchSuggestions, onRecentSearchClick = { search -> viewModel.applyRecentSearch(search) focusManager.clearFocus() }, - onRecentSearchRemove = viewModel::removeRecentSearch, onClearAllRecentSearches = { viewModel.clearAllRecentSearches() focusManager.clearFocus() @@ -297,7 +314,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { SearchTabs( types = state.supportedSearchTypes, selectedType = selectedType, - onTabSelected = viewModel::onTabSelected) + onTabSelected = viewModel::onTabSelected + ) } } @@ -305,14 +323,16 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { state.isLoadingSearchTypes -> { SearchLoadingIndicator( modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == null -> { SearchMessage( message = "No search types available", modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.ALL -> { @@ -348,7 +368,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.TRACK -> { @@ -379,7 +400,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.PLAYLIST -> { @@ -388,7 +410,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { playlists = state.playlists, onLoadNextPage = viewModel::loadNextPlaylists, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.ALBUM -> { @@ -397,7 +420,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { albums = state.albums, onLoadNextPage = viewModel::loadNextAlbums, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.ARTIST -> { @@ -406,7 +430,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { artists = state.artists, onLoadNextPage = viewModel::loadNextArtists, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.USER -> { @@ -415,7 +440,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { users = state.users, onLoadNextPage = viewModel::loadNextUsers, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } } } @@ -439,11 +465,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { private fun SearchBar( query: String, onQueryChange: (String) -> Unit, - recentSearches: List, + recentSearchSuggestions: List, onRecentSearchClick: (String) -> Unit, - onRecentSearchRemove: (String) -> Unit, - onClearAllRecentSearches: () -> Unit) { - val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty() + onClearAllRecentSearches: () -> Unit +) { + val hasRecentSearchSuggestions = recentSearchSuggestions.isNotEmpty() val searchState = rememberTextFieldState(query) LaunchedEffect(searchState) { @@ -455,106 +481,61 @@ private fun SearchBar( } } - val suggestions = remember(showRecentSearches, recentSearches) { - buildList { - if (showRecentSearches) { - add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches)) - recentSearches.forEach { search -> - add( - SearchDropdownEntry.RecentSearch( - query = search, - onClick = { onRecentSearchClick(search) }, - onRemove = { onRecentSearchRemove(search) }) - ) - } - } - } - } - - Box( + MillenniumAutocomplete( + state = searchState, + suggestions = recentSearchSuggestions, + onSuggestionSelected = onRecentSearchClick, + suggestionLabel = { it }, + leadingContent = { + MillenniumIcon( + imageVector = Iconsax.InconsaxClock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + }, + placeholder = stringResource(Res.string.search_placeholder), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 8.dp)) { - MillenniumAutocomplete( - state = searchState, - - suggestions = suggestions, - onSuggestionSelected = { entry -> entry.onClick() }, - suggestionLabel = { entry -> - when (entry) { - is SearchDropdownEntry.ClearAll -> "Clear all history" - is SearchDropdownEntry.RecentSearch -> entry.query - } - }, - placeholder = "Search songs, artists, albums...", - modifier = Modifier.fillMaxWidth(), - suggestionContent = { entry, _ -> SearchSuggestionRow(entry) }) - } -} - -private sealed interface SearchDropdownEntry { - val onClick: () -> Unit - - data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry - data class RecentSearch( - val query: String, - override val onClick: () -> Unit, - val onRemove: () -> Unit) : SearchDropdownEntry -} - -@Composable -private fun SearchSuggestionRow(entry: SearchDropdownEntry) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp)) { - when (entry) { - is SearchDropdownEntry.ClearAll -> { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - tint = AppTheme.colors.error, - modifier = Modifier.size(16.dp)) - Text( - "Clear all history", - color = AppTheme.colors.error, - style = AppTheme.typography.bodyMedium) - } - - is SearchDropdownEntry.RecentSearch -> { - MillenniumIcon( - imageVector = Iconsax.InconsaxClock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = AppTheme.colors.onSurfaceVariant) - Text( - entry.query, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = AppTheme.typography.bodyMedium, - modifier = Modifier.weight(1f)) - MillenniumIconButton( - onClick = entry.onRemove, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = FeatherIcons.X, - contentDescription = "Remove", - modifier = Modifier.size(14.dp), - tint = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f)) - } + .padding(bottom = 8.dp), + menuHeader = if (hasRecentSearchSuggestions) { + { + MillenniumListTile( + title = { + MillenniumText( + text = stringResource(Res.string.search_clear_history), + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium + ) + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.error + ) + }, + onClick = onClearAllRecentSearches, + minimumHeight = 36.dp, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + leadingSpacing = 8.dp, + shape = AppTheme.shapes.small + ) } + } else { + null } - } + ) } @Composable private fun SearchTabs( types: List, selectedType: MetadataSupportedSearchType?, - onTabSelected: (MetadataSupportedSearchType) -> Unit) { + onTabSelected: (MetadataSupportedSearchType) -> Unit +) { val listState = rememberLazyListState() LazyRow( state = listState, @@ -570,7 +551,8 @@ private fun SearchTabs( MillenniumButtonStyle.Primary } else { MillenniumButtonStyle.Secondary - }) { + } + ) { Text(type.tabTitle()) } } @@ -600,7 +582,8 @@ private fun SearchAllTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -624,7 +607,8 @@ private fun SearchAllTab( ErrorDisplay( errorMessage = hasAnyError, onRetry = { }, - modifier = modifier) + modifier = modifier + ) return } @@ -665,7 +649,8 @@ private fun SearchAllTab( text = "Tracks", style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp)) + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp) + ) } if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) { if (tracks.isLoading && tracks.items.isEmpty()) { @@ -743,7 +728,8 @@ private fun SearchTracksTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { if (query.isBlank()) { SearchMessage(message = "Start typing to search tracks", modifier = modifier) return @@ -775,7 +761,8 @@ private fun SearchTracksTab( onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, simplified = true, - modifier = modifier) + modifier = modifier + ) } @Composable @@ -783,7 +770,8 @@ private fun SearchAlbumsTab( query: String, albums: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = albums.items, @@ -793,7 +781,8 @@ private fun SearchAlbumsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No albums found", loadingMessage = "Loading albums...", - modifier = modifier) { album -> + modifier = modifier + ) { album -> AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) } } @@ -803,7 +792,8 @@ private fun SearchArtistsTab( query: String, artists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = artists.items, @@ -813,7 +803,8 @@ private fun SearchArtistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No artists found", loadingMessage = "Loading artists...", - modifier = modifier) { artist -> + modifier = modifier + ) { artist -> ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) } } @@ -823,7 +814,8 @@ private fun SearchPlaylistsTab( query: String, playlists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = playlists.items, @@ -833,7 +825,8 @@ private fun SearchPlaylistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No playlists found", loadingMessage = "Loading playlists...", - modifier = modifier) { playlist -> + modifier = modifier + ) { playlist -> PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) } } @@ -843,7 +836,8 @@ private fun SearchUsersTab( query: String, users: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = users.items, @@ -853,7 +847,8 @@ private fun SearchUsersTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No users found", loadingMessage = "Loading users...", - modifier = modifier) { user -> + modifier = modifier + ) { user -> UserCard(user = user, modifier = Modifier.fillMaxWidth()) } } @@ -870,7 +865,8 @@ private fun SearchGridTab( emptyMessage: String, loadingMessage: String, modifier: Modifier = Modifier, - itemContent: @Composable (T) -> Unit) { + itemContent: @Composable (T) -> Unit +) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -897,14 +893,16 @@ private fun SearchGridTab( isLoading && items.isEmpty() -> { SearchLoadingIndicator( message = loadingMessage, - modifier = modifier) + modifier = modifier + ) } error != null && items.isEmpty() -> { ErrorDisplay( errorMessage = error, onRetry = onLoadNextPage, - modifier = modifier) + modifier = modifier + ) } !isLoading && items.isEmpty() -> { @@ -918,7 +916,8 @@ private fun SearchGridTab( modifier = modifier.padding(horizontal = 12.dp), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp)) { + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { items(items) { item -> itemContent(item) } @@ -929,7 +928,8 @@ private fun SearchGridTab( PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -942,32 +942,38 @@ private fun SearchGridTab( private fun SearchHorizontalSection( title: String, onSeeAll: () -> Unit, - content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) { + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit +) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp)) { + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( text = title, style = AppTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold) + fontWeight = FontWeight.SemiBold + ) Text( text = "See all", style = AppTheme.typography.labelLarge, color = AppTheme.colors.primary, - modifier = Modifier.clickable(onClick = onSeeAll)) + modifier = Modifier.clickable(onClick = onSeeAll) + ) } LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - content = content) + content = content + ) } } @@ -975,31 +981,37 @@ private fun SearchHorizontalSection( private fun SearchTrackRow( index: Int, track: MetadataTrack, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { Row( modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp)) { + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { Text( text = (index + 1).toString(), style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant, - textAlign = TextAlign.End) + textAlign = TextAlign.End + ) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp)) { + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { Text( text = track.title, style = AppTheme.typography.bodyLarge, - maxLines = 1) + maxLines = 1 + ) Text( text = track.artists.joinToString { it.name }, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, - maxLines = 1) + maxLines = 1 + ) } if (track.album != null) Text( @@ -1007,7 +1019,8 @@ private fun SearchTrackRow( style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - modifier = Modifier.weight(1f)) + modifier = Modifier.weight(1f) + ) } } @@ -1018,27 +1031,33 @@ private val WarmSearchMessages = listOf( "Dusting off the vinyl...", "Consulting the music gods...", "Tuning the antennas...", - "Flipping through the records...") + "Flipping through the records..." +) @Composable private fun SearchLoadingIndicator( message: String? = null, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { val infiniteTransition = rememberInfiniteTransition(label = "search-loading") val rotation by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart), - label = "icon-rotation") + repeatMode = RepeatMode.Restart + ), + label = "icon-rotation" + ) val bounce by infiniteTransition.animateFloat( initialValue = -8f, targetValue = 8f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 800, easing = LinearEasing), - repeatMode = RepeatMode.Reverse), - label = "icon-bounce") + repeatMode = RepeatMode.Reverse + ), + label = "icon-bounce" + ) val messageIndex by infiniteTransition.animateFloat( initialValue = 0f, targetValue = WarmSearchMessages.size.toFloat(), @@ -1047,18 +1066,22 @@ private fun SearchLoadingIndicator( durationMillis = WarmSearchMessages.size * 2000, easing = LinearEasing ), - repeatMode = RepeatMode.Restart), - label = "message-cycle") + repeatMode = RepeatMode.Restart + ), + label = "message-cycle" + ) val displayMessage = message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size] Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { + verticalArrangement = Arrangement.Center + ) { Box( modifier = Modifier.size(64.dp), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { MillenniumIcon( imageVector = Iconsax.IconsaxSearchBroken, contentDescription = "Searching", @@ -1073,18 +1096,21 @@ private fun SearchLoadingIndicator( MillenniumIndeterminateProgress( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp)) + .padding(horizontal = 32.dp, vertical = 16.dp) + ) AnimatedContent( targetState = displayMessage, transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }, - label = "message-transition") { msg -> + label = "message-transition" + ) { msg -> Text( text = msg, color = AppTheme.colors.onSurfaceVariant, style = AppTheme.typography.bodyMedium, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center + ) } } } @@ -1093,30 +1119,35 @@ private fun SearchLoadingIndicator( private fun SearchMessage( message: String, modifier: Modifier = Modifier, - isError: Boolean = false) { + isError: Boolean = false +) { Box( modifier = modifier, - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { Text( text = message, color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, style = AppTheme.typography.bodyMedium, textAlign = TextAlign.Center, - modifier = Modifier.padding(16.dp)) + modifier = Modifier.padding(16.dp) + ) } } @Composable private fun SearchMessageInline( message: String, - isError: Boolean = false) { + isError: Boolean = false +) { Text( text = message, color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, style = AppTheme.typography.bodyMedium, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp)) + .padding(horizontal = 16.dp) + ) } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt index 5134106a..44b0b425 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt @@ -59,7 +59,14 @@ data class SearchScreenState( val albums: SearchPagedState = SearchPagedState(), val artists: SearchPagedState = SearchPagedState(), val playlists: SearchPagedState = SearchPagedState(), - val users: SearchPagedState = SearchPagedState()) + val users: SearchPagedState = SearchPagedState()) { + val recentSearchSuggestions: List + get() = if (query.isBlank()) { + recentSearches + } else { + recentSearches.filter { it.contains(query, ignoreCase = true) } + } +} @OptIn(ExperimentalCoroutinesApi::class) class SearchScreenViewModel( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 2a5d0ed8..38428743 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -50,10 +50,14 @@ import dev.krtirtho.spotube.modules.support.LocalSupportPrompt import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 +import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken +import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken import dev.krtirtho.spotube.sidebarTabs import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumSidebar import org.spotube.millennium.composables.MillenniumSidebarContent import org.spotube.millennium.composables.MillenniumSidebarFooter @@ -89,7 +93,7 @@ fun AppSidebar( ) { MillenniumSidebarHeader { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { @@ -104,7 +108,14 @@ fun AppSidebar( color = AppTheme.colors.onSurface ) } - MillenniumSidebarTrigger() + MillenniumSidebarTrigger { + MillenniumIconButton( + if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken, + contentDescription = "Toggle Sidebar", + style = MillenniumIconButtonStyle.Quiet, + onClick = { it.toggle() } + ) + } } } MillenniumSidebarContent { @@ -120,8 +131,9 @@ fun AppSidebar( MillenniumSidebarGroupContent { MillenniumSidebarMenu { entry.items.forEach { item -> - val selected = navigationState.topLevelRoute == item.route && - (item.route != Routes.Library || currentLibraryTab == item.data) + val selected = + navigationState.topLevelRoute == item.route && + (item.route != Routes.Library || currentLibraryTab == item.data) SidebarNavItem( item = item, selected = selected, @@ -154,13 +166,15 @@ fun AppSidebar( label = supportLabel, onClick = { openSupport() }, contentDescription = supportLabel, - icon = Iconsax.IconsaxHeart2) + icon = Iconsax.IconsaxHeart2 + ) MillenniumSidebarItem( label = stringResource(Res.string.settings_screen_title), onClick = { navigator.navigate(Routes.Settings) }, selected = navigationState.topLevelRoute == Routes.Settings, contentDescription = stringResource(Res.string.settings_screen_title), - icon = Iconsax.IconsaxSetting2) + icon = Iconsax.IconsaxSetting2 + ) } } } @@ -169,11 +183,13 @@ fun AppSidebar( private fun SidebarNavItem( item: NavigationItem.Tab<*>, selected: Boolean, - onClick: () -> Unit) { + onClick: () -> Unit +) { MillenniumSidebarItem( label = item.title, onClick = onClick, selected = selected, contentDescription = item.title, - icon = item.icon) + icon = item.icon + ) } From fda5bb8a6063f9890cfcb597824bbee4ccb6f505 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 16:41:48 +0600 Subject: [PATCH 044/132] feat(sidebar): adjust spacing in sidebar and setting cards for improved layout --- .../library/playlist/PlaylistFormSheet.kt | 4 +- .../components/InteractiveSettingCards.kt | 57 +++++++++++-------- .../spotube/modules/shell/AppSidebar.kt | 11 +++- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index 7bd7bc80..7cc96696 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -255,7 +255,7 @@ private fun PlaylistFormFields( style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) } - MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange, label = "Public") + MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange) } Row( @@ -273,7 +273,7 @@ private fun PlaylistFormFields( style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) } - MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange, label = "Collaborative") + MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) } Column( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index 58fe3c3c..a5b9d8ac 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -18,8 +18,6 @@ package dev.krtirtho.spotube.modules.settings.components - - import androidx.compose.foundation.layout.Box import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints @@ -65,7 +63,8 @@ internal fun SwitchSettingCard( subtitle: String? = null, checked: Boolean, onCheckedChange: (Boolean) -> Unit, - icon: (@Composable () -> Unit)? = null) { + icon: (@Composable () -> Unit)? = null +) { SettingCardItem( title = title, subtitle = subtitle, @@ -74,10 +73,8 @@ internal fun SwitchSettingCard( MillenniumSwitch( checked = checked, onCheckedChange = onCheckedChange, - // The row itself already renders the title/subtitle, so the switch must not - // draw a second visible label next to it. - label = "", - contentSpacing = 0.dp) + contentSpacing = 0.dp + ) }, onClick = { onCheckedChange(!checked) @@ -96,7 +93,8 @@ internal fun SelectionSettingCard( dialogTitle: String = title, icon: (@Composable () -> Unit)? = null, inlineSelectorMinWidth: Dp = 700.dp, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null +) { var isDialogOpen by remember { mutableStateOf(false) } Box { @@ -112,24 +110,29 @@ internal fun SelectionSettingCard( AdaptiveMenuItem( label = optionLabel(option), onClick = { onOptionSelected(option) }, - selected = option == selectedOption) + selected = option == selectedOption + ) }, trigger = { onClick -> MillenniumButton( onClick = onClick, - style = MillenniumButtonStyle.Secondary) { + style = MillenniumButtonStyle.Secondary + ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp)) { + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { Text(optionLabel(selectedOption)) MillenniumIcon( imageVector = Iconsax.IconsaxArrowDown4, contentDescription = null, - modifier = Modifier.size(14.dp)) + modifier = Modifier.size(14.dp) + ) } } }, - filter = filter) + filter = filter + ) }, onClick = { if (isWideLayout) { @@ -143,7 +146,8 @@ internal fun SelectionSettingCard( MillenniumDialog( visible = true, onDismissRequest = { isDialogOpen = false }, - title = dialogTitle) { + title = dialogTitle + ) { MillenniumRadioGroup( value = selectedOption, onValueChange = { option -> @@ -155,7 +159,8 @@ internal fun SelectionSettingCard( optionLabel = { option -> labels[options.indexOf(option)] }) MillenniumButton( onClick = { isDialogOpen = false }, - style = MillenniumButtonStyle.Quiet) { + style = MillenniumButtonStyle.Quiet + ) { Text(stringResource(Res.string.settings_action_close)) } } @@ -177,7 +182,8 @@ internal fun TextInputSettingCard( icon: (@Composable () -> Unit)? = null, inlineTextFieldMinWidth: Dp = 700.dp, inlineTextFieldWidth: Dp = 220.dp, - enabled: Boolean = true) { + enabled: Boolean = true +) { var isDialogOpen by remember { mutableStateOf(false) } Box { @@ -204,10 +210,10 @@ internal fun TextInputSettingCard( { MillenniumTextField( state = inlineState, - label = title, modifier = Modifier.width(inlineTextFieldWidth), placeholder = placeholder, - enabled = enabled) + enabled = enabled + ) } } else { null @@ -235,19 +241,22 @@ internal fun TextInputSettingCard( MillenniumDialog( visible = true, onDismissRequest = { isDialogOpen = false }, - title = dialogTitle) { + title = dialogTitle + ) { if (dialogDescription != null) { Text( text = dialogDescription, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } MillenniumTextField( state = dialogState, label = title, modifier = Modifier.fillMaxWidth(), placeholder = placeholder, - error = errorMessage) + error = errorMessage + ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumButton( onClick = { @@ -256,12 +265,14 @@ internal fun TextInputSettingCard( isDialogOpen = false } }, - enabled = errorMessage == null) { + enabled = errorMessage == null + ) { Text(stringResource(Res.string.settings_action_save)) } MillenniumButton( onClick = { isDialogOpen = false }, - style = MillenniumButtonStyle.Secondary) { + style = MillenniumButtonStyle.Secondary + ) { Text(stringResource(Res.string.settings_action_cancel)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 38428743..9adcd54f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -118,7 +118,9 @@ fun AppSidebar( } } } - MillenniumSidebarContent { + MillenniumSidebarContent( + contentSpacing = 0.dp + ) { sidebarTabs.forEach { entry -> when (entry) { is NavigationItem.Group<*> -> { @@ -126,10 +128,13 @@ fun AppSidebar( MillenniumSidebarGroup( expanded = isGroupExpanded, collapsible = true, + groupSpacing = 0.dp, onExpandedChange = { groupExpansion[entry.title] = it }) { MillenniumSidebarGroupLabel(entry.title) - MillenniumSidebarGroupContent { - MillenniumSidebarMenu { + MillenniumSidebarGroupContent( + contentSpacing = 0.dp + ) { + MillenniumSidebarMenu(contentSpacing = 0.dp) { entry.items.forEach { item -> val selected = navigationState.topLevelRoute == item.route && From d5eaf101eb5594c938d284c95aee9445fba634db Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 19:26:54 +0600 Subject: [PATCH 045/132] fix: add window insets to top bar and bottom navigation bar, queue sheet --- .../drawable/plugin_logo_newpipe.png | Bin 0 -> 19614 bytes .../drawable/plugin_logo_newpipe.svg | 17 -------------- .../core/webview/PlatformWebviewScreen.kt | 5 ++--- .../spotube/modules/shell/AppShell.kt | 21 ++++++++++++------ .../modules/shell/AppShellViewModel.kt | 9 +++----- .../modules/shell/player_queue/QueueSheet.kt | 8 ++++++- .../ui/component/ApplicationMainBar.mobile.kt | 5 ++++- gradle.properties | 6 ++++- gradle/libs.versions.toml | 4 ++-- 9 files changed, 37 insertions(+), 38 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png delete mode 100644 composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png new file mode 100644 index 0000000000000000000000000000000000000000..3bcf07994e4605558e752644704abe451bc08f9d GIT binary patch literal 19614 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg deleted file mode 100644 index 7cb4554d..00000000 --- a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index d166f722..583ba6e2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -156,7 +155,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) { ApplicationMainBar( title = { Row( - modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), + modifier = Modifier.fillMaxWidth().height(56.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -227,4 +226,4 @@ fun PlatformWebViewScreen(webViewController: WebViewController) { navigator.evaluateJavaScript(bridgeBootstrapScript) }) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index bdd89d61..f5c9e948 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -128,7 +127,11 @@ fun AppShell( Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar() - val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) + val density = LocalDensity.current + val navigationBarInset = with(density) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) + navigationBarInset CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) { if (useSidebar) { @@ -278,7 +281,9 @@ private fun CompactPlayerOverlay( val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() } val bottomBarHeightPx = with(density) { viewModel.bottomBarHeight.toPx() } val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() - val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx + val playerBarSpacingPx = with(density) { viewModel.floatingPlayerSpacing.toPx() } + val peekHeightPx = + floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx + playerBarSpacingPx val peekHeight = with(density) { peekHeightPx.toDp() } val partialDetent = remember(peekHeight) { @@ -318,7 +323,6 @@ private fun CompactPlayerOverlay( Column(modifier = Modifier.fillMaxWidth()) { AppFloatingPlayer( modifier = Modifier - .offset(y = 12.dp) .graphicsLayer { alpha = uiState.floatingPlayerAlpha }) @@ -332,9 +336,12 @@ private fun CompactPlayerOverlay( AppBottombar( navigator = navigator, navigationState = navigationState, - modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }) + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .graphicsLayer { + alpha = uiState.floatingPlayerAlpha + }) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt index 1e8fd536..768f44c1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt @@ -42,9 +42,9 @@ data class LyricsOverlayState( class AppShellViewModel : ViewModel() { val sidebarMinWidth = 840.dp - val bottomBarEstimatedHeight = 58.dp val bottomBarHeight = 80.dp - val floatingPlayerHeight = 74.dp + val floatingPlayerHeight = 76.dp + val floatingPlayerSpacing = 12.dp val largePlayerInset = 100.dp val floatingPlayerDismissProgress = 0.7f val expandedPlayerVisibilityThreshold = 0.001f @@ -53,11 +53,8 @@ class AppShellViewModel : ViewModel() { val isLyricsOverlayVisible: StateFlow = lyricsOverlayVisible.asStateFlow() - val floatingPlayerBottomOffset: Dp - get() = bottomBarEstimatedHeight + 8.dp - val compactChromeInset: Dp - get() = floatingPlayerBottomOffset + floatingPlayerHeight + 10.dp + get() = bottomBarHeight + floatingPlayerHeight + floatingPlayerSpacing @Composable fun useSidebar(maxWidth: Dp? = null): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index 6175b72a..207bec37 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -34,6 +34,9 @@ import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.rememberMillenniumDrawerState private val SlidingSheetBreakpoint = 840.dp +private val MaxHeightSheetDetent = SheetDetent("max-height-85-percent") { containerHeight, sheetHeight -> + minOf(sheetHeight, containerHeight * 0.85f) +} @Composable fun QueueSheet( @@ -85,7 +88,10 @@ internal fun BottomSheetContent( content: @Composable () -> Unit ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + val sheetState = rememberModalBottomSheetState( + initialDetent = MaxHeightSheetDetent, + detents = listOf(SheetDetent.Hidden, MaxHeightSheetDetent) + ) LaunchedEffect(sheetState.bottomSheetState.currentDetent) { if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { onDismiss() diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt index fd455041..b60bd16c 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt @@ -18,6 +18,8 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBars import org.spotube.millennium.composables.MillenniumTopAppBar import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable @@ -33,10 +35,11 @@ actual fun ApplicationMainBar( title = title, subtitle = subtitle, actions = actions, + windowInsets = WindowInsets.statusBars, navigationIcon = { if (backButton) { ApplicationBackButton() } } ) -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index b96a5a61..e2904b99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,4 +27,8 @@ org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +# Preserve compatibility with this project's current Kotlin Multiplatform Android setup on AGP 9. +android.builtInKotlin=false +android.newDsl=false +android.sourceset.disallowProvider=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index efeac1e2..24df3449 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ # along with this program. If not, see . [versions] -agp = "8.13.2" +agp = "9.1.0" android-compileSdk = "37" android-minSdk = "28" android-targetSdk = "36" @@ -193,4 +193,4 @@ uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } -neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" } \ No newline at end of file +neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" } From 5b442a93a9befb33ff7494f03cae0341aa5edad9 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 20:39:54 +0600 Subject: [PATCH 046/132] feat(jam): implement group jam functionality with UI components and state management --- .../composeResources/values/strings.xml | 34 + .../krtirtho/spotube/modules/jam/JamScreen.kt | 633 +++++++++++------- .../spotube/modules/jam/JamViewModel.kt | 149 ++++- .../spotube/modules/shell/AppBottombar.kt | 12 +- .../modules/shell/AppFloatingPlayer.kt | 124 ++-- .../spotube/modules/shell/AppShell.kt | 111 ++- .../modules/shell/AppShellViewModel.kt | 34 +- .../spotube/modules/jam/JamLayoutTest.kt | 192 ++++++ 8 files changed, 933 insertions(+), 356 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 32787564..b8052b87 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -234,11 +234,45 @@ Identity Diagnostics + + Group Jam + Create + Join + Create a room, then share its code with your friends. + Create room + Join room + Connecting… + Room code + ABC123 + Broker: %1$s + Set up a broker in Settings to host or join a room. + Broker settings + Connected + Reconnecting… + Copy code + Copied! + Share + Participants (%1$d) + Host + Listener + Waiting for listeners… + Remove + Ban + Manage + Leave room + Couldn’t connect + Dismiss + Failed to create jam session + Failed to join jam session + Enter a six-character room code using letters and digits. + Join my Spotube Jam with code: %1$s + Go to album Sleep timer Track details Play Pause + Nothing playing Add to queue Ignore diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index 8693e7d3..9180c0f8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,311 +17,468 @@ package dev.krtirtho.spotube.modules.jam +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import org.spotube.millennium.theme.MillenniumTheme -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumSeparator -import org.spotube.millennium.composables.MillenniumSegmentedButton -import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults -import org.spotube.millennium.composables.MillenniumSegmentedButtonRow -import org.spotube.millennium.composables.MillenniumTextField - -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomCode +import com.composeunstyled.Text import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* -/** - * Group Jam session screen. Playback controls and the queue live in the app's - * regular player / queue sheet (the shared jam queue is the local queue), so - * this screen only covers participation and session management. - */ @Composable -fun JamScreen( - navigationCommands: NavigationCommands) { +fun JamScreen(navigationCommands: NavigationCommands) { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() + val clipboard = LocalClipboardManager.current val shellBottomInset = LocalAppShellBottomInset.current - Column( - modifier = Modifier - .fillMaxSize() - .background(MillenniumTheme.colors.background)) { - ApplicationMainBar( - backButton = true, - title = { Text("Group Jam") }) + Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + ApplicationMainBar(title = { Text(stringResource(Res.string.jam_screen_title)) }) Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(16.dp) - .padding(bottom = shellBottomInset) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp)) { - state.error?.let { error -> - ErrorBanner(text = error, onDismiss = viewModel::clearError) - } - - when { - !state.isActive -> CreateOrJoinView( - state = state, - onCreate = viewModel::createSession, - onJoin = viewModel::joinWithCode) - - else -> SessionView( - state = state, - onShareCode = viewModel::shareRoomCode, - onLeave = viewModel::leave, - onKick = viewModel::kickParticipant, - onBan = viewModel::banParticipant) + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = shellBottomInset + 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + Modifier.widthIn(max = 520.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + state.error?.let { error -> + JamPanel { + Text( + error, + color = AppTheme.colors.error, + style = AppTheme.typography.bodySmall + ) + MillenniumButton( + onClick = viewModel::clearError, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.jam_error_dismiss)) + } + } + } + if (state.isActive) { + SessionPanel( + state = state, + onCopy = { + clipboard.setText(AnnotatedString(state.roomCode.orEmpty())) + viewModel.onCodeCopied() + }, + onShare = viewModel::shareRoomCode, + onLeave = viewModel::leave, + onKick = viewModel::kickParticipant, + onBan = viewModel::banParticipant, + onMenuChange = viewModel::showParticipantMenu + ) + } else { + SetupPanel( + state, + viewModel::selectSetupMode, + viewModel::createSession, + viewModel::joinSession + ) + } + BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) } } } } } @Composable -private fun ErrorBanner(text: String, onDismiss: () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = text, - color = AppTheme.colors.error, - style = AppTheme.typography.bodyMedium) - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { - Text("Dismiss") - } - } +private fun JamPanel(content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(10.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + innerGlowColor = MillenniumTheme.colors.highlight.copy(alpha = 0.25f) + ).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content + ) } @Composable -private fun CreateOrJoinView( +internal fun SetupPanel( state: JamUiState, + onMode: (JamSetupMode) -> Unit, onCreate: () -> Unit, - onJoin: (String) -> Unit) { - var tab by rememberSaveable { mutableIntStateOf(0) } - val codeState = rememberTextFieldState() - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", - style = AppTheme.typography.titleMedium) - - if (!state.brokerConfigured) { + onJoin: () -> Unit +) { + JamPanel { + MillenniumSegmentedButtonRow(Modifier.fillMaxWidth()) { + MillenniumSegmentedButton( + selected = state.setupMode == JamSetupMode.Create, + onClick = { onMode(JamSetupMode.Create) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2) + ) { Text(stringResource(Res.string.jam_setup_create_tab)) } + MillenniumSegmentedButton( + selected = state.setupMode == JamSetupMode.Join, + onClick = { onMode(JamSetupMode.Join) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2) + ) { Text(stringResource(Res.string.jam_setup_join_tab)) } + } + if (state.setupMode == JamSetupMode.Create) { + SharedRoomIllustration(Modifier.fillMaxWidth().height(100.dp)) Text( - text = "No jam broker configured — set one up in Settings to host or join a session.", - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.error) - } else { - Text( - text = "Broker: ${state.brokerHost}", + stringResource(Res.string.jam_setup_create_hint), style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) - } - - MillenniumSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - MillenniumSegmentedButton( - selected = tab == 0, - onClick = { tab = 0 }, - shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { Text("Create") } - MillenniumSegmentedButton( - selected = tab == 1, - onClick = { tab = 1 }, - shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { Text("Join") } - } - - if (tab == 0) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Start a session as the host. You'll get a 6-character room code to " + - "share with friends; you control the queue.", - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) - MillenniumButton( - onClick = onCreate, - enabled = state.brokerConfigured) { - Text("Create Session") - } - } + color = AppTheme.colors.onSurfaceVariant + ) } else { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Enter the 6-character room code the host shared with you.", - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) - MillenniumTextField( - state = codeState, - label = "Room code", - placeholder = "ABC123", - modifier = Modifier.fillMaxWidth()) - val normalizedCode = JamRoomCode.normalize(codeState.text.toString()) - MillenniumButton( - onClick = { onJoin(normalizedCode) }, - enabled = state.brokerConfigured && JamRoomCode.isValid(normalizedCode)) { - Text("Join Session") - } - } + MillenniumTextField( + state = state.codeInput, + label = stringResource(Res.string.jam_setup_code_label), + placeholder = stringResource(Res.string.jam_setup_code_placeholder), + enabled = !state.isBusy, + textStyle = AppTheme.typography.headlineSmall.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 4.sp + ), + modifier = Modifier.fillMaxWidth() + ) + } + MillenniumButton( + onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin, + enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource( + when { + state.isBusy -> Res.string.jam_setup_working + state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button + else -> Res.string.jam_setup_join_button + } + ) + ) } } } +/** A small media-player diagram: one source feeding two listeners. No stock artwork. */ @Composable -private fun SessionView( +private fun SharedRoomIllustration(modifier: Modifier) { + val colors = MillenniumTheme.colors + Canvas(modifier) { + val scale = minOf(size.width / 260f, size.height / 100f) + val origin = Offset((size.width - 260f * scale) / 2f, (size.height - 100f * scale) / 2f) + fun point(x: Float, y: Float) = origin + Offset(x * scale, y * scale) + val stroke = 1.2f * scale + val metal = Brush.verticalGradient( + listOf(colors.raised, colors.surface), + startY = origin.y, + endY = origin.y + 100f * scale + ) + drawLine(colors.border, point(45f, 50f), point(215f, 50f), 2f * scale) + drawLine(colors.accent.copy(alpha = 0.7f), point(45f, 49f), point(215f, 49f), stroke) + // Two miniature speakers, with an accent power LED and concentric drivers. + for (x in listOf(15f, 207f)) { + drawRoundRect( + metal, + point(x, 18f), + Size(38f * scale, 64f * scale), + CornerRadius(5f * scale) + ) + drawRoundRect( + colors.border, + point(x, 18f), + Size(38f * scale, 64f * scale), + CornerRadius(5f * scale), + style = Stroke(stroke) + ) + drawCircle(colors.background, 10f * scale, point(x + 19f, 57f)) + drawCircle(colors.border, 10f * scale, point(x + 19f, 57f), style = Stroke(stroke)) + drawCircle(colors.raised, 5f * scale, point(x + 19f, 57f)) + drawCircle(colors.background, 4f * scale, point(x + 19f, 34f)) + drawCircle(colors.accent, 1.5f * scale, point(x + 19f, 74f)) + } + // Brushed-metal source unit with a backlit equalizer display. + drawRoundRect( + metal, + point(82f, 10f), + Size(96f * scale, 80f * scale), + CornerRadius(8f * scale) + ) + drawRoundRect( + colors.border, + point(82f, 10f), + Size(96f * scale, 80f * scale), + CornerRadius(8f * scale), + style = Stroke(stroke) + ) + drawRoundRect( + colors.highlight.copy(alpha = 0.25f), + point(86f, 13f), + Size(88f * scale, 1f * scale), + CornerRadius(scale) + ) + drawRoundRect( + colors.background, + point(92f, 22f), + Size(76f * scale, 30f * scale), + CornerRadius(3f * scale) + ) + val bars = listOf(8f, 16f, 12f, 22f, 18f, 10f, 15f, 7f) + bars.forEachIndexed { index, height -> + drawRoundRect( + colors.accent.copy(alpha = 0.85f), + point(99f + index * 8f, 47f - height), + Size(4f * scale, height * scale), + CornerRadius(scale) + ) + } + drawCircle(colors.background, 11f * scale, point(130f, 70f)) + drawCircle(colors.border, 11f * scale, point(130f, 70f), style = Stroke(stroke)) + drawPath(Path().apply { + moveTo(point(127f, 65f).x, point(127f, 65f).y) + lineTo(point(135f, 70f).x, point(135f, 70f).y) + lineTo(point(127f, 75f).x, point(127f, 75f).y) + close() + }, colors.text) + for (x in listOf(101f, 159f)) drawRoundRect( + colors.border, + point(x - 4f, 68f), + Size(8f * scale, 4f * scale), + CornerRadius(scale) + ) + } +} + +@Composable +internal fun SessionPanel( state: JamUiState, - onShareCode: () -> Unit, + onCopy: () -> Unit, + onShare: () -> Unit, onLeave: () -> Unit, onKick: (String) -> Unit, - onBan: (String) -> Unit) { - val isHost = state.role == JamRole.Host - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (!state.isConnected) { + onBan: (String) -> Unit, + onMenuChange: (String?) -> Unit +) { + JamPanel { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + Modifier.size(7.dp).background( + if (state.isConnected) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant, + CircleShape + ) + ) Text( - text = "Connecting to the session…", - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) + stringResource(if (state.isConnected) Res.string.jam_session_connected else Res.string.jam_session_connecting), + style = AppTheme.typography.labelMedium + ) } - - ParticipantsSection( - participants = state.participants, - isHost = isHost, - onKick = onKick, - onBan = onBan) - - if (isHost) { - MillenniumSeparator() - + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(5.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { Text( - text = "Invite someone", - style = AppTheme.typography.titleSmall) - RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) - } - - MillenniumSeparator() - - Text( - text = "The queue and playback controls are in the player at the bottom of the app — " + - "the jam queue is shared with every participant.", - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) - - LeaveButton(onLeave) - } -} - -@Composable -private fun ParticipantsSection( - participants: List, - isHost: Boolean, - onKick: (String) -> Unit, - onBan: (String) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "Participants (${participants.size})", - style = AppTheme.typography.titleSmall) - participants.forEach { participant -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { + stringResource(Res.string.jam_setup_code_label), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + SelectionContainer { Text( - text = participant.displayName, - style = AppTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f)) - if (participant.isHost) { - Text( - text = "Host", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.primary) - } - if (isHost && !participant.isHost) { - MillenniumButton( - onClick = { onKick(participant.id) }, - modifier = Modifier.height(32.dp), - style = MillenniumButtonStyle.Secondary) { - Text("Kick", style = AppTheme.typography.labelSmall) - } - MillenniumButton( - onClick = { onBan(participant.id) }, - modifier = Modifier.height(32.dp), - style = MillenniumButtonStyle.Secondary) { - Text("Ban", style = AppTheme.typography.labelSmall) - } - } + state.roomCode.orEmpty(), + style = AppTheme.typography.headlineMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 5.sp, + color = AppTheme.colors.primary + ) } } - } -} - -@Composable -private fun RoomCodeBox( - code: String, - onShare: () -> Unit) { - val clipboard = LocalClipboardManager.current - val viewModel: JamViewModel = koinViewModel() - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SelectionContainer { - Text( - text = code, - style = AppTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - color = AppTheme.colors.primary, - modifier = Modifier.padding(vertical = 8.dp)) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MillenniumButton( - onClick = { clipboard.setText(AnnotatedString(code)) }) { - Text("Copy") - } - if (viewModel.supportsNativeShare) { + if (state.isHost) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { MillenniumButton( + onClick = onCopy, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(if (state.codeCopied) Res.string.jam_invite_copied else Res.string.jam_invite_copy)) + } + if (state.supportsNativeShare) MillenniumButton( onClick = onShare, - style = MillenniumButtonStyle.Secondary) { - Text("Share") + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.jam_invite_share)) } } } + MillenniumSeparator() + Text( + stringResource(Res.string.jam_participants_heading, state.participants.size), + style = AppTheme.typography.titleSmall + ) + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(5.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.participants.isEmpty()) Text( + stringResource(Res.string.jam_participants_waiting), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + state.participants.forEach { participant -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(32.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = CircleShape, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), contentAlignment = Alignment.Center + ) { + Text(participant.initial, style = AppTheme.typography.labelMedium) + } + Column(Modifier.weight(1f)) { + Text( + participant.displayName, + style = AppTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(if (participant.isHost) Res.string.jam_participants_host else Res.string.jam_participants_guest), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + if (participant.canModerate) { + val removeLabel = stringResource(Res.string.jam_participants_remove) + val banLabel = stringResource(Res.string.jam_participants_ban) + // Keep destructive moderation out of the participant's primary row. + org.spotube.millennium.composables.MillenniumDropdownMenu( + expanded = participant.menuExpanded, + onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) }, + items = listOf(removeLabel, banLabel), + onItemClick = { label -> + if (label == removeLabel) onKick(participant.id) else onBan( + participant.id + ) + }, + anchor = { + MillenniumButton( + onClick = { onMenuChange(participant.id) }, + style = MillenniumButtonStyle.Secondary + ) { Text(stringResource(Res.string.jam_participants_manage)) } + } + ) + } + } + } + } + MillenniumButton( + onClick = onLeave, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(Res.string.jam_session_leave)) + } } } @Composable -private fun LeaveButton(onLeave: () -> Unit) { - MillenniumButton( - onClick = onLeave, - style = MillenniumButtonStyle.Secondary) { - Text("Leave Session") +internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + if (state.brokerConfigured) stringResource( + Res.string.jam_connection_host, + state.brokerHost + ) else stringResource(Res.string.jam_connection_missing), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + MillenniumTooltip(stringResource(Res.string.jam_connection_settings)) { + MillenniumIconButton(onClick = onConfigure, style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.IconsaxSetting2, + contentDescription = stringResource(Res.string.jam_connection_settings) + ) + } + } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt index c361668d..2748f093 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -19,6 +19,8 @@ package dev.krtirtho.spotube.modules.jam import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamRole @@ -30,27 +32,57 @@ import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.Job +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* + +enum class JamSetupMode { Create, Join } + +data class JamParticipantUi( + val id: String, + val displayName: String, + val initial: String, + val isHost: Boolean, + val canModerate: Boolean, + val menuExpanded: Boolean = false +) data class JamUiState( val isActive: Boolean = false, val isConnected: Boolean = false, val role: JamRole? = null, - val participants: List = emptyList(), + val participants: List = emptyList(), val roomCode: String? = null, val brokerHost: String = "", val brokerConfigured: Boolean = false, - val error: String? = null) + val error: String? = null, + val setupMode: JamSetupMode = JamSetupMode.Create, + val codeInput: TextFieldState = TextFieldState(), + val canCreate: Boolean = false, + val canJoin: Boolean = false, + val isBusy: Boolean = false, + val isHost: Boolean = false, + val supportsNativeShare: Boolean = false, + val codeCopied: Boolean = false +) class JamViewModel( private val jamRoomService: JamRoomService, private val shareService: ShareService, - private val settingsProvider: SettingsProvider) : ViewModel() { + private val settingsProvider: SettingsProvider +) : ViewModel() { private val _localError = MutableStateFlow(null) + private val setupMode = MutableStateFlow(JamSetupMode.Create) + private val isBusy = MutableStateFlow(false) + private val codeCopied = MutableStateFlow(false) + private val codeInput = TextFieldState() + private var copyFeedbackJob: Job? = null + private val participantMenu = MutableStateFlow(null) val supportsNativeShare: Boolean = getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS @@ -62,9 +94,16 @@ class JamViewModel( jamRoomService.roomCode, jamRoomService.connectionError, settingsProvider.settingsState, - _localError) { values -> + _localError, + setupMode, + isBusy, + codeCopied, + snapshotFlow { codeInput.text.toString() }, + participantMenu + ) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? + @Suppress("UNCHECKED_CAST") val participants = values[1] as List val isConnected = values[2] as Boolean @@ -72,48 +111,108 @@ class JamViewModel( val connectionError = values[4] as String? val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings val localError = values[6] as String? + val busy = values[8] as Boolean + val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank() JamUiState( isActive = role != null, isConnected = isConnected, role = role, - participants = participants, + participants = participants.sortedByDescending { it.isHost }.map { participant -> + JamParticipantUi( + id = participant.id, + displayName = participant.displayName, + initial = participant.displayName.firstOrNull()?.uppercase() ?: "?", + isHost = participant.isHost, + canModerate = role == JamRole.Host && !participant.isHost, + menuExpanded = values[11] == participant.id + ) + }, roomCode = roomCode, brokerHost = settings?.jamBroker?.host.orEmpty(), - brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), - error = localError ?: connectionError) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) + brokerConfigured = brokerConfigured, + error = localError ?: connectionError, + setupMode = values[7] as JamSetupMode, + isBusy = busy, + codeCopied = values[9] as Boolean, + codeInput = codeInput, + canCreate = brokerConfigured && !busy, + canJoin = brokerConfigured && !busy && + JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), + isHost = role == JamRole.Host, + supportsNativeShare = supportsNativeShare + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + JamUiState(codeInput = codeInput) + ) // ---------- Session lifecycle ---------- fun createSession() { + if (isBusy.value) return + isBusy.value = true viewModelScope.launch { - jamRoomService.createRoom() - .onFailure { e -> - _localError.value = e.message ?: "Failed to create jam session" - } - .onSuccess { _localError.value = null } + try { + jamRoomService.createRoom() + .onFailure { e -> + _localError.value = e.message ?: getString(Res.string.jam_error_create) + } + .onSuccess { _localError.value = null } + } finally { + isBusy.value = false + } + } + } + + fun selectSetupMode(mode: JamSetupMode) { + setupMode.value = mode + _localError.value = null + } + + fun joinSession() = joinWithCode(codeInput.text.toString()) + + fun onCodeCopied() { + copyFeedbackJob?.cancel() + copyFeedbackJob = viewModelScope.launch { + codeCopied.value = true + delay(2_000) + codeCopied.value = false } } fun joinWithCode(input: String) { + if (isBusy.value) return val code = JamRoomCode.normalize(input) if (!JamRoomCode.isValid(code)) { - _localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)" + viewModelScope.launch { + _localError.value = getString(Res.string.jam_error_invalid_code) + } return } + isBusy.value = true viewModelScope.launch { - jamRoomService.joinRoom(code) - .onFailure { e -> - _localError.value = e.message ?: "Failed to join jam session" - } - .onSuccess { _localError.value = null } + try { + jamRoomService.joinRoom(code) + .onFailure { e -> + _localError.value = e.message ?: getString(Res.string.jam_error_join) + } + .onSuccess { _localError.value = null } + } finally { + isBusy.value = false + } } } fun shareRoomCode() { val code = uiState.value.roomCode ?: return - shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam") + viewModelScope.launch { + shareService.share( + getString(Res.string.jam_share_message, code), + getString(Res.string.jam_screen_title) + ) + } } fun leave() { @@ -130,10 +229,16 @@ class JamViewModel( // ---------- Host moderation ---------- fun kickParticipant(participantId: String) { + participantMenu.value = null viewModelScope.launch { jamRoomService.kickParticipant(participantId) } } fun banParticipant(participantId: String) { + participantMenu.value = null viewModelScope.launch { jamRoomService.banParticipant(participantId) } } -} \ No newline at end of file + + fun showParticipantMenu(participantId: String?) { + participantMenu.value = participantId + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt index c7e4cd30..96b7f4c7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt @@ -35,7 +35,9 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator fun AppBottombar( navigator: Navigator, navigationState: NavigationState, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier, + libraryBadge: @Composable () -> Unit = { DownloadBadgeIndicator() } +) { MillenniumBottomNavigationBar(modifier = modifier) { bottombarTabs.filterIsInstance>() .forEach { tab -> @@ -48,14 +50,16 @@ fun AppBottombar( MillenniumIcon( imageVector = tab.icon, contentDescription = null, - tint = LocalTextStyle.current.color) + tint = LocalTextStyle.current.color + ) }, label = { Text(tab.title) }, badge = if (tab.route == Routes.Library) { - { DownloadBadgeIndicator() } + libraryBadge } else { null - }) + } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt index 3c7bcab8..420234b8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt @@ -23,7 +23,8 @@ import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumProgress import org.spotube.millennium.theme.millenniumSurface -import androidx.compose.ui.graphics.SolidColor +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -38,43 +39,40 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue 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.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_nothing_playing import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +internal val FloatingPlayerContentHeight = 64.dp +internal val FloatingPlayerBottomBleed = 16.dp + // The floating player on small screens that appears above the floating AppBottombar // // on left the album art, song title, artists grouped together @@ -94,17 +92,12 @@ fun AppFloatingPlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - )) { + ) +) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val scope = rememberCoroutineScope() val albumArtModifier = rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } - var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - - LaunchedEffect(playerUiState.progress) { - seekProgress = playerUiState.progress - } fun onPlayPause() { scope.launch { @@ -116,29 +109,58 @@ fun AppFloatingPlayer( } } + FloatingPlayerContent( + playerUiState = playerUiState, + modifier = modifier, + albumArtModifier = albumArtModifier, + onPlayPause = ::onPlayPause, + heartButton = { + PlayerHeartButton( + audioPlayerQueue = audioPlayerQueue, + savedTracksViewModel = savedTracksViewModel + ) + } + ) +} + +@Composable +internal fun FloatingPlayerContent( + playerUiState: PlayerUiState, + modifier: Modifier = Modifier, + albumArtModifier: Modifier = Modifier, + onPlayPause: () -> Unit, + heartButton: @Composable () -> Unit +) { + val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + Box( modifier = modifier .fillMaxWidth() - .height(76.dp) + .height(FloatingPlayerContentHeight + FloatingPlayerBottomBleed) .millenniumSurface( - background = SolidColor(AppTheme.colors.inverseSurface), - shape = RoundedCornerShape(24.dp, 24.dp))) { - Box( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + ) + ) { + Column( modifier = Modifier - .fillMaxSize()) { + .fillMaxSize() + // Only the surface extends beneath the navigation bar, not the controls. + .padding(bottom = FloatingPlayerBottomBleed) + ) { MillenniumProgress( - progress = seekProgress.coerceIn(0f, 1f), + progress = playerUiState.progress, modifier = Modifier .fillMaxWidth() .height(2.dp) ) - + Spacer(modifier = Modifier.height(10.dp)) Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp) - .padding(bottom = 22.dp, top = 8.dp) - .align(Alignment.Center), + .padding(horizontal = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -148,30 +170,36 @@ fun AppFloatingPlayer( ) { Box( modifier = Modifier - .size(48.dp) + .size(42.dp) .then(albumArtModifier) - .clip(RoundedCornerShape(16.dp)) - .background(AppTheme.colors.onSurface.copy(alpha = 0.12f)) + .clip(RoundedCornerShape(6.dp)) + .background(AppTheme.colors.onSurface.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center ) { - AsyncImage( + if (coverModel != null) AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier .fillMaxSize(), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) + else MillenniumIcon( + Iconsax.IconsaxMusic, contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, modifier = Modifier.size(22.dp) + ) } Column(modifier = Modifier.padding(start = 10.dp)) { Text( - text = playerUiState.title, + text = if (playerUiState.queue.isEmpty()) stringResource(Res.string.player_nothing_playing) else playerUiState.title, style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.inverseOnSurface, + color = AppTheme.colors.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( + if (playerUiState.queue.isNotEmpty()) Text( text = playerUiState.artists, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.inverseOnSurface.copy(alpha = 0.72f), + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -179,17 +207,17 @@ fun AppFloatingPlayer( } Row(verticalAlignment = Alignment.CenterVertically) { - PlayerHeartButton( - audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel) + heartButton() Spacer(modifier = Modifier.size(8.dp)) MillenniumIconButton( - onClick = ::onPlayPause, + onClick = onPlayPause, enabled = playerUiState.queue.isNotEmpty(), - style = MillenniumIconButtonStyle.Secondary) { + style = MillenniumIconButtonStyle.Secondary + ) { MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play") + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description) + ) } } } @@ -201,12 +229,14 @@ fun AppFloatingPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope? +): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope) + animatedVisibilityScope = animatedVisibilityScope + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index f5c9e948..d2a2714b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf @@ -43,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -83,7 +85,8 @@ fun AppShell( viewModel: AppShellViewModel = koinViewModel(), queueViewModel: PlayerQueueContentViewModel = koinViewModel(), alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel(), - content: @Composable () -> Unit) { + content: @Composable () -> Unit +) { val navigatorCommands: NavigationCommands = koinInject() val remotePlaybackController: RemotePlaybackController = koinInject() val snackbarHostState = remember { MillenniumSnackbarHostState() } @@ -127,11 +130,13 @@ fun AppShell( Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar() + val chromeUiState by viewModel.compactChromeUiState.collectAsState() val density = LocalDensity.current val navigationBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } - val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) + navigationBarInset + val bottomOverlayInset = + viewModel.bottomOverlayInset(useSidebar, navigationBarInset, chromeUiState) CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) { if (useSidebar) { @@ -153,8 +158,11 @@ fun AppShell( ) MillenniumSeparator( modifier = Modifier.fillMaxHeight(), - vertical = true) - Box(modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { + vertical = true + ) + Box( + modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState) + ) { content() } } @@ -187,19 +195,23 @@ fun AppShell( QueueSheet( isVisible = isQueueVisible, onDismiss = { queueViewModel.setQueueVisibility(false) }, - modifier = Modifier.fillMaxSize()) { + modifier = Modifier.fillMaxSize() + ) { PlayerQueueContent( viewModel = queueViewModel, - modifier = Modifier.fillMaxSize()) + modifier = Modifier.fillMaxSize() + ) } AlternativeTrackSheet( isVisible = isAlternativeVisible, onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize()) { + modifier = Modifier.fillMaxSize() + ) { AlternativeTrackContent( viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize()) + modifier = Modifier.fillMaxSize() + ) } } } else { @@ -207,7 +219,8 @@ fun AppShell( Box( modifier = Modifier .fillMaxSize() - .background(MillenniumTheme.colors.background)) + .background(MillenniumTheme.colors.background) + ) CompactPlayerOverlay( navigator = navigator, navigationState = navigationState, @@ -215,7 +228,8 @@ fun AppShell( onQueue = queueViewModel::toggleQueueVisibility, onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, onExpandLyrics = viewModel::showLyricsOverlay, - content = content) + content = content + ) QueueSheet( isVisible = isQueueVisible, @@ -224,16 +238,19 @@ fun AppShell( ) { PlayerQueueContent( viewModel = queueViewModel, - modifier = Modifier.fillMaxSize()) + modifier = Modifier.fillMaxSize() + ) } AlternativeTrackSheet( isVisible = isAlternativeVisible, onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize()) { + modifier = Modifier.fillMaxSize() + ) { AlternativeTrackContent( viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize()) + modifier = Modifier.fillMaxSize() + ) } if (isLyricsOverlayVisible) { @@ -242,8 +259,13 @@ fun AppShell( properties = DialogProperties( usePlatformDefaultWidth = false, dismissOnBackPress = true, - dismissOnClickOutside = true)) { - Box(modifier = Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + dismissOnClickOutside = true + ) + ) { + Box( + modifier = Modifier.fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { LyricsScreen( modifier = Modifier.fillMaxSize(), onClose = { viewModel.hideLyricsOverlay() }) @@ -260,40 +282,56 @@ fun AppShell( hostState = snackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = bottomOverlayInset + 12.dp)) + .padding(bottom = bottomOverlayInset + 12.dp) + ) } } @OptIn( - ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) + ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class +) @Composable -private fun CompactPlayerOverlay( +internal fun CompactPlayerOverlay( navigator: Navigator, navigationState: NavigationState, viewModel: AppShellViewModel, onQueue: () -> Unit, onAlternativeSource: () -> Unit, onExpandLyrics: () -> Unit, - content: @Composable () -> Unit) { + floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) }, + bottomBar: @Composable (Modifier) -> Unit = { + AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it) + }, + content: @Composable () -> Unit +) { val density = LocalDensity.current val scope = rememberCoroutineScope() + val chromeUiState by viewModel.compactChromeUiState.collectAsState() val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() } - val bottomBarHeightPx = with(density) { viewModel.bottomBarHeight.toPx() } val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() - val playerBarSpacingPx = with(density) { viewModel.floatingPlayerSpacing.toPx() } - val peekHeightPx = - floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx + playerBarSpacingPx + val bottomBarHeightPx = with(density) { + chromeUiState.measuredBottomBarHeight?.toPx() + ?: (viewModel.bottomBarHeight.toPx() + navBarInsetPx) + } + val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx val peekHeight = with(density) { peekHeightPx.toDp() } val partialDetent = remember(peekHeight) { SheetDetent("player-peek") { _, _ -> peekHeight } } - val sheetState = rememberBottomSheetState( - initialDetent = partialDetent, - detents = listOf(partialDetent, SheetDetent.FullyExpanded)) + // The library remembers its detent definitions at creation. Recreate the state when + // the measured chrome height changes, rather than keeping a stale peek anchor. + val sheetState = key(peekHeight) { + rememberBottomSheetState( + initialDetent = partialDetent, + detents = listOf(partialDetent, SheetDetent.FullyExpanded) + ) + } - val progress = runCatching { + val progress = if (!sheetState.offset.isFinite() || + (sheetState.isIdle && sheetState.currentDetent == partialDetent) + ) 0f else runCatching { sheetState.progress(partialDetent, SheetDetent.FullyExpanded) }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f)) @@ -303,7 +341,8 @@ private fun CompactPlayerOverlay( UnstyledBottomSheet( state = sheetState, - modifier = Modifier.fillMaxSize()) { + modifier = Modifier.fillMaxSize() + ) { Sheet(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) { if (uiState.showExpandedPlayer) { @@ -316,13 +355,14 @@ private fun CompactPlayerOverlay( }, onQueue = onQueue, onAlternativeSource = onAlternativeSource, - onExpandLyrics = onExpandLyrics) + onExpandLyrics = onExpandLyrics + ) } if (uiState.floatingPlayerAlpha > 0.01f) { Column(modifier = Modifier.fillMaxWidth()) { - AppFloatingPlayer( - modifier = Modifier + floatingPlayer( + Modifier .graphicsLayer { alpha = uiState.floatingPlayerAlpha }) @@ -333,12 +373,13 @@ private fun CompactPlayerOverlay( } if (uiState.floatingPlayerAlpha > 0.01f) { - AppBottombar( - navigator = navigator, - navigationState = navigationState, - modifier = Modifier + bottomBar( + Modifier .fillMaxWidth() .align(Alignment.BottomCenter) + .onSizeChanged { size -> + viewModel.onBottomBarMeasured(with(density) { size.height.toDp() }) + } .graphicsLayer { alpha = uiState.floatingPlayerAlpha }) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt index 768f44c1..e5e22813 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt @@ -35,27 +35,34 @@ data class CompactSheetUiState( val showExpandedPlayer: Boolean, val expandedPlayerAlpha: Float, val floatingPlayerAlpha: Float, - val bottomBarAlpha: Float) + val bottomBarAlpha: Float +) data class LyricsOverlayState( - val isVisible: Boolean = false) + val isVisible: Boolean = false +) + +data class CompactChromeUiState(val measuredBottomBarHeight: Dp? = null) class AppShellViewModel : ViewModel() { val sidebarMinWidth = 840.dp val bottomBarHeight = 80.dp - val floatingPlayerHeight = 76.dp - val floatingPlayerSpacing = 12.dp + // The background bleed overlaps the bottom bar and is not part of the peek height. + val floatingPlayerHeight = FloatingPlayerContentHeight val largePlayerInset = 100.dp val floatingPlayerDismissProgress = 0.7f val expandedPlayerVisibilityThreshold = 0.001f private val lyricsOverlayVisible = MutableStateFlow(false) + private val compactChrome = MutableStateFlow(CompactChromeUiState()) + val compactChromeUiState = compactChrome.asStateFlow() + + fun onBottomBarMeasured(height: Dp) { + if (height > 0.dp) compactChrome.value = CompactChromeUiState(height) + } val isLyricsOverlayVisible: StateFlow = lyricsOverlayVisible.asStateFlow() - val compactChromeInset: Dp - get() = bottomBarHeight + floatingPlayerHeight + floatingPlayerSpacing - @Composable fun useSidebar(maxWidth: Dp? = null): Boolean { if (maxWidth != null) { @@ -64,8 +71,14 @@ class AppShellViewModel : ViewModel() { return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded } - fun bottomOverlayInset(useSidebar: Boolean): Dp = - if (useSidebar) largePlayerInset else compactChromeInset + fun bottomOverlayInset( + useSidebar: Boolean, + navigationBarInset: Dp, + chrome: CompactChromeUiState + ): Dp = + if (useSidebar) largePlayerInset + navigationBarInset + else (chrome.measuredBottomBarHeight ?: (bottomBarHeight + navigationBarInset)) + + floatingPlayerHeight fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float = if (sheetHeightPx > 0f) { @@ -85,7 +98,8 @@ class AppShellViewModel : ViewModel() { showExpandedPlayer = progress > expandedPlayerVisibilityThreshold, expandedPlayerAlpha = progress, floatingPlayerAlpha = floatingPlayerAlpha(progress), - bottomBarAlpha = 1f - progress) + bottomBarAlpha = 1f - progress + ) } fun showLyricsOverlay() { diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt new file mode 100644 index 00000000..44efa651 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt @@ -0,0 +1,192 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.jam + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme +import dev.krtirtho.spotube.core.navigation.Navigator +import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES +import dev.krtirtho.spotube.core.navigation.rememberNavigationState +import dev.krtirtho.spotube.modules.settings.Theme +import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.shell.AppShellViewModel +import dev.krtirtho.spotube.modules.shell.FloatingPlayerContent +import dev.krtirtho.spotube.modules.shell.FloatingPlayerBottomBleed +import dev.krtirtho.spotube.modules.shell.PlayerUiState +import dev.krtirtho.spotube.modules.shell.AppBottombar +import dev.krtirtho.spotube.modules.shell.CompactPlayerOverlay +import org.spotube.millennium.composables.MillenniumTopAppBar +import org.jetbrains.compose.resources.stringResource +import com.composeunstyled.Text +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_screen_title +import org.spotube.millennium.theme.MillenniumTheme +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class JamLayoutTest { + @Test + fun measuredDockHeightIncludesSystemInsetExactlyOnce() { + val viewModel = AppShellViewModel() + viewModel.onBottomBarMeasured(76.dp) + assertEquals( + 140.dp, + viewModel.bottomOverlayInset(false, 0.dp, viewModel.compactChromeUiState.value) + ) + viewModel.onBottomBarMeasured(100.dp) + assertEquals( + 164.dp, + viewModel.bottomOverlayInset(false, 24.dp, viewModel.compactChromeUiState.value) + ) + viewModel.onBottomBarMeasured(0.dp) + assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight) + } + + /** Actual Compose controls rendered without native-window capture or an MQTT connection. */ + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderRoomControlsAtNarrowAndWideSizes() { + val output = File("build/reports/jam-ui").apply { mkdirs() } + val states = mapOf( + "create" to JamUiState( + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + canCreate = true + ), + "join" to JamUiState( + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + setupMode = JamSetupMode.Join + ), + "host" to JamUiState( + isActive = true, + isConnected = true, + isHost = true, + roomCode = "RC5FJ8", + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + participants = listOf( + JamParticipantUi("host", "Host-8316", "H", true, false), + JamParticipantUi("guest", "Guest-6684", "G", false, true) + ) + ), + "guest" to JamUiState( + isActive = true, + isConnected = true, + roomCode = "RC5FJ8", + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false)) + ) + ) + for (width in listOf(360, 920)) { + for ((name, state) in states) { + var playerBottom = Float.NaN + var navigationTop = Float.NaN + var navigationBottom = Float.NaN + ImageComposeScene(width, 760) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Column( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MillenniumTopAppBar(title = { Text(stringResource(Res.string.jam_screen_title)) }) + val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES) + val navigator = remember(navigation) { Navigator(navigation) } + Box(Modifier.weight(1f).fillMaxWidth()) { + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigation, + viewModel = remember { AppShellViewModel() }, + onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {}, + floatingPlayer = { modifier -> + FloatingPlayerContent( + PlayerUiState(), + modifier = modifier.onGloballyPositioned { + playerBottom = it.boundsInRoot().bottom + }, + onPlayPause = {}, + heartButton = {}) + }, + bottomBar = { modifier -> + AppBottombar( + navigator, + navigation, + modifier = modifier.onGloballyPositioned { + navigationTop = it.boundsInRoot().top + navigationBottom = it.boundsInRoot().bottom + }, + libraryBadge = {}) + } + ) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + Modifier.widthIn(max = 520.dp).fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.isActive) SessionPanel( + state, + {}, + {}, + {}, + {}, + {}, + {}) + else SetupPanel(state, {}, {}, {}) + BrokerStatus(state, {}) + } + } + } + } + } + } + }.use { scene -> + repeat(8) { scene.render().close() } + assertTrue(playerBottom.isFinite()) + assertEquals( + playerBottom - FloatingPlayerBottomBleed.value, + navigationTop, + "Only the player's bottom surface bleed should overlap navigation" + ) + assertEquals( + 760f, + navigationBottom, + "Navigation must reach the window's bottom edge" + ) + scene.render().use { image -> + val data = assertNotNull(image.encodeToData()) + data.use { File(output, "$name-$width.png").writeBytes(it.bytes) } + } + } + } + } + } +} From cf9476ab089f53673ee84e6e06c1b03b6ccee3f3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 21:52:24 +0600 Subject: [PATCH 047/132] feat(library): enhance library UI with new tab titles, filtering options, and improved state management --- composeApp/build.gradle.kts | 1 + .../composeResources/values/strings.xml | 16 ++ .../core/ui/component/CollectionView.kt | 12 +- .../spotube/modules/library/LibraryScreen.kt | 232 ++++++++++++------ .../spotube/modules/library/LibraryState.kt | 81 +++++- .../spotube/modules/library/LibraryTab.kt | 22 +- .../library/album/LibraryAlbumsScreen.kt | 25 -- .../library/album/LibraryAlbumsViewModel.kt | 42 ++-- .../library/artist/LibraryArtistsScreen.kt | 57 ++--- .../library/artist/LibraryArtistsViewModel.kt | 45 ++-- .../local_tracks/LibraryLocalTracksScreen.kt | 61 ++--- .../LibraryLocalTracksViewModel.kt | 59 +++-- .../playlist/LibraryPlaylistsScreen.kt | 64 +---- .../playlist/LibraryPlaylistsViewModel.kt | 52 ++-- .../spotube/modules/search/SearchScreen.kt | 57 ++++- .../modules/library/LibraryToolbarTest.kt | 191 ++++++++++++++ gradle.properties | 5 +- 17 files changed, 693 insertions(+), 329 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2d8729d9..750ed2c6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -108,6 +108,7 @@ kotlin { } jvm() + jvmToolchain(21) sourceSets { val commonMain by getting { diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b8052b87..373fc2e4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -25,6 +25,22 @@ Clear all history Search... + + Your Library + Playlists + Albums + Artists + Local Tracks + Downloads + Search saved playlists… + Search saved albums… + Search saved artists… + Search folders or tracks… + Filter library + Close filter + Create playlist + Refresh local tracks + Selected Settings diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 5434ce34..1b660b4c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -24,15 +24,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -44,16 +37,21 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.composeunstyled.Text import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle @Composable fun CollectionView( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index 1dbef564..de796a5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -17,102 +17,192 @@ package dev.krtirtho.spotube.modules.library - -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow - -import com.composeunstyled.Text +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import com.composeunstyled.currentWindowWidthBreakpoint +import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.isDesktop import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.downloads.DownloadsScreen import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen -import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField +import spotube.composeapp.generated.resources.* @Composable -fun LibraryScreen( - libraryState: LibraryState = koinInject(), - appShellViewModel: AppShellViewModel = koinViewModel() -) { - val currentTab by libraryState.currentTab.collectAsState() - val searchMode by libraryState.searchMode.collectAsState() - val isLargeScreen = appShellViewModel.useSidebar() +fun LibraryScreen(libraryState: LibraryState = koinInject()) { + val state by libraryState.uiState.collectAsStateWithLifecycle() + val expandedDesktop = getPlatform().isDesktop() && + (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current - Column(modifier = Modifier.fillMaxSize()) { - Column { - ApplicationMainBar( - backButton = false, - title = { - if (!isLargeScreen) Text("Your Library") - }, - actions = { - if (!isLargeScreen) { - MillenniumIconButton( - icon = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - onClick = { - libraryState.setSearchMode(!searchMode) - }, - style = MillenniumIconButtonStyle.Quiet) - } - } - ) - if (!isLargeScreen) - Column( - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Spacer(modifier = Modifier.height(4.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(LibraryTab.entries.size) { index -> - val tab = LibraryTab.entries[index] - MillenniumButton( - onClick = { libraryState.onTabSelected(tab) }, - style = if (currentTab == tab) { - MillenniumButtonStyle.Primary - } else { - MillenniumButtonStyle.Secondary - }) { - Text(tab.title) - } - } - } - Spacer(modifier = Modifier.height(12.dp)) + Column(Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = false, + title = { LibraryToolbarTitle(state, expandedDesktop) }, + actions = { + LibraryToolbarActions( + state = state, + expandedDesktop = expandedDesktop, + onOpenSearch = libraryState::openSearch, + onCloseSearch = { + focusManager.clearFocus() + keyboard?.hide() + libraryState.closeSearch() + }, + onCreatePlaylist = libraryState::showCreatePlaylist, + onRefresh = libraryState::refreshLocalTracks + ) + } + ) + if (!expandedDesktop) { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(LibraryTab.entries) { tab -> + MillenniumButton( + onClick = { libraryState.onTabSelected(tab) }, + style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary + ) { Text(stringResource(tab.titleResource)) } } + } } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + when (state.currentTab) { + LibraryTab.Playlists -> LibraryPlaylistsScreen( + showCreatePlaylist = state.showCreatePlaylist, + onDismissCreatePlaylist = libraryState::dismissCreatePlaylist + ) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 12.dp) - ) { - when (currentTab) { - LibraryTab.Playlists -> LibraryPlaylistsScreen(searchMode = isLargeScreen || searchMode) - LibraryTab.Albums -> LibraryAlbumsScreen(searchMode = isLargeScreen || searchMode) - LibraryTab.Artists -> LibraryArtistsScreen(searchMode = isLargeScreen || searchMode) - LibraryTab.LocalTracks -> LibraryLocalTracksScreen(searchMode = isLargeScreen || searchMode) + LibraryTab.Albums -> LibraryAlbumsScreen() + LibraryTab.Artists -> LibraryArtistsScreen() + LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.Downloads -> DownloadsScreen() } } } -} \ No newline at end of file +} + +@Composable +internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) { + if (!expandedDesktop && state.searchMode) { + LibraryFilterField( + state.searchState, + state.currentTab.filterPlaceholder, + Modifier.fillMaxWidth(), + autoFocus = true + ) + } else if (!expandedDesktop) { + Text(stringResource(Res.string.library_screen_title)) + } +} + +@Composable +internal fun RowScope.LibraryToolbarActions( + state: LibraryToolbarUiState, + expandedDesktop: Boolean, + onOpenSearch: () -> Unit, + onCloseSearch: () -> Unit, + onCreatePlaylist: () -> Unit, + onRefresh: () -> Unit +) { + if (!expandedDesktop && state.searchMode) { + MillenniumIconButton( + icon = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.library_action_close_search), + onClick = onCloseSearch, + style = MillenniumIconButtonStyle.Quiet + ) + } else { + if (state.canFilter) { + if (expandedDesktop) { + LibraryFilterField( + state.searchState, + state.currentTab.filterPlaceholder, + Modifier.width(300.dp) + ) + } else { + MillenniumIconButton( + icon = Iconsax.IconsaxSearchBroken, + contentDescription = stringResource(Res.string.library_action_search), + onClick = onOpenSearch, + style = MillenniumIconButtonStyle.Quiet + ) + } + } + if (state.canCreatePlaylist) { + MillenniumIconButton( + icon = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.library_action_create_playlist), + onClick = onCreatePlaylist, + style = MillenniumIconButtonStyle.Quiet + ) + } + if (state.canRefresh) { + MillenniumIconButton( + icon = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), + onClick = onRefresh, + style = MillenniumIconButtonStyle.Quiet + ) + } + } +} + +@Composable +private fun LibraryFilterField( + state: TextFieldState, + placeholder: StringResource, + modifier: Modifier, + autoFocus: Boolean = false +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { + if (autoFocus) focusRequester.requestFocus() + } + MillenniumTextField( + state = state, + placeholder = stringResource(placeholder), + minimumHeight = 36.dp, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester) + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt index 56de67de..9002784f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt @@ -17,17 +17,90 @@ package dev.krtirtho.spotube.modules.library +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn -class LibraryState { - val searchMode = MutableStateFlow(false) +data class LibraryToolbarUiState( + val currentTab: LibraryTab = LibraryTab.Playlists, + val searchMode: Boolean = false, + val searchState: TextFieldState = TextFieldState(), + val canFilter: Boolean = true, + val canCreatePlaylist: Boolean = true, + val canRefresh: Boolean = false, + val showCreatePlaylist: Boolean = false +) + +/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */ +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryState : ViewModel() { + private val searchMode = MutableStateFlow(false) val currentTab = MutableStateFlow(LibraryTab.Playlists) + private val searchStates = LibraryTab.entries.associateWith { TextFieldState() } + private val createPlaylistVisible = MutableStateFlow(false) + private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) + val localRefreshRequests = refreshRequests.asSharedFlow() + + val uiState = combine( + currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, + searchMode, + createPlaylistVisible + ) { (tab, query), searching, creating -> + LibraryToolbarUiState( + currentTab = tab, + searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), + searchState = searchStates.getValue(tab), + canFilter = tab != LibraryTab.Downloads, + canCreatePlaylist = tab == LibraryTab.Playlists, + canRefresh = tab == LibraryTab.LocalTracks, + showCreatePlaylist = tab == LibraryTab.Playlists && creating + ) + }.stateIn( + viewModelScope, SharingStarted.Eagerly, + LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists)) + ) + + fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() } + + fun setQuery(tab: LibraryTab, query: String) { + searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query) + } fun onTabSelected(tab: LibraryTab) { currentTab.value = tab + createPlaylistVisible.value = false + if (tab == LibraryTab.Downloads) searchMode.value = false } - fun setSearchMode(enabled: Boolean) { - searchMode.value = enabled + fun openSearch() { + if (currentTab.value != LibraryTab.Downloads) searchMode.value = true + } + + fun closeSearch() { + setQuery(currentTab.value, "") + searchMode.value = false + } + + fun showCreatePlaylist() { + if (currentTab.value == LibraryTab.Playlists) createPlaylistVisible.value = true + } + + fun dismissCreatePlaylist() { + createPlaylistVisible.value = false + } + + fun refreshLocalTracks() { + refreshRequests.tryEmit(Unit) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt index a64ebe96..3c9abd4d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt @@ -24,11 +24,31 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary import dev.krtirtho.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.* enum class LibraryTab(val title: String, val icon: ImageVector) { Playlists("Playlists", Iconsax.IconsaxMusicDashboard), Albums("Albums", Iconsax.IconsaxCd), Artists("Artists", Iconsax.User), LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary), - Downloads("Downloads", Iconsax.IconsaxDirectboxReceive), + Downloads("Downloads", Iconsax.IconsaxDirectboxReceive); + + val titleResource: StringResource + get() = when (this) { + Playlists -> Res.string.library_tab_playlists + Albums -> Res.string.library_tab_albums + Artists -> Res.string.library_tab_artists + LocalTracks -> Res.string.library_tab_local_tracks + Downloads -> Res.string.library_tab_downloads + } + + val filterPlaceholder: StringResource + get() = when (this) { + Playlists -> Res.string.library_filter_playlists + Albums -> Res.string.library_filter_albums + Artists -> Res.string.library_filter_artists + LocalTracks -> Res.string.library_filter_local_tracks + Downloads -> Res.string.search_filter_placeholder + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index ed4b7a00..b422147f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -18,17 +18,12 @@ package dev.krtirtho.spotube.modules.library.album -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumTextField -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -43,35 +38,24 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @Composable fun LibraryAlbumsScreen( modifier: Modifier = Modifier, - searchMode: Boolean = false, viewModel: LibraryAlbumsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() - val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "") - LaunchedEffect(searchState) { - snapshotFlow { searchState.text.toString() } - .drop(1) - .collect(viewModel::onQueryChange) - } LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } @@ -93,15 +77,6 @@ fun LibraryAlbumsScreen( } Column(modifier = modifier) { - AnimatedVisibility(searchMode) { - MillenniumTextField( - state = searchState, - - modifier = Modifier.fillMaxWidth(), - placeholder = "Search saved albums..." - ) - } - Spacer(modifier = Modifier.height(12.dp)) when (state) { is LibraryAlbumsState.Loading -> { val shellBottomInset = LocalAppShellBottomInset.current diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 4b48f0c8..60fd0b39 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.library.LibraryRepository +import dev.krtirtho.spotube.modules.library.LibraryState +import dev.krtirtho.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -46,7 +49,8 @@ sealed interface LibraryAlbumsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -62,13 +66,15 @@ sealed interface LibraryAlbumsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination) + nextPagination = nextPagination + ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -88,11 +94,21 @@ sealed interface LibraryAlbumsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryAlbumsViewModel( - private val repository: LibraryRepository) : ViewModel(), KoinComponent { + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryAlbumsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Albums) + ) { state, query -> + when (state) { + is LibraryAlbumsState.Data.Loaded -> state.copy(query = query) + is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading) init { viewModelScope.launch { @@ -113,7 +129,8 @@ class LibraryAlbumsViewModel( val result = repository.savedAlbums() _state.value = LibraryAlbumsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) }.onFailure { e -> logger.e(e) { "Failed to load albums" } _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") @@ -127,7 +144,8 @@ class LibraryAlbumsViewModel( _state.value = LibraryAlbumsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } @@ -135,13 +153,7 @@ class LibraryAlbumsViewModel( } fun onQueryChange(query: String) { - val currentState = _state.value - if (currentState is LibraryAlbumsState.Data) { - _state.value = when (currentState) { - is LibraryAlbumsState.Data.Loaded -> currentState.copy(query = query) - is LibraryAlbumsState.Data.LoadingMore -> currentState.copy(query = query) - } - } + libraryState.setQuery(LibraryTab.Albums, query) } fun refresh() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index eadb5bce..2f64b93c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -18,23 +18,16 @@ package dev.krtirtho.spotube.modules.library.artist -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumTextField -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState - -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -43,34 +36,25 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @Composable fun LibraryArtistsScreen( modifier: Modifier = Modifier, - searchMode: Boolean = false, - viewModel: LibraryArtistsViewModel = koinViewModel()) { + viewModel: LibraryArtistsViewModel = koinViewModel() +) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() - val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "") - LaunchedEffect(searchState) { - snapshotFlow { searchState.text.toString() } - .drop(1) - .collect(viewModel::onQueryChange) - } LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } @@ -92,14 +76,6 @@ fun LibraryArtistsScreen( } Column(modifier = modifier) { - AnimatedVisibility(searchMode){ - MillenniumTextField( - state = searchState, - - modifier = Modifier.fillMaxWidth(), - placeholder = "Search saved artists...") - } - Spacer(modifier = Modifier.height(12.dp)) when (state) { is LibraryArtistsState.Loading -> { val shellBottomInset = LocalAppShellBottomInset.current @@ -111,13 +87,15 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp)) { + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { items(8) { SkeletonTree(true) { AvatarCard( title = "Artist Name", subtitle = "Artist", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -152,22 +130,25 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp)) { + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { items(dataState.items, key = { it.id }) { artist -> ArtistCard( artist = artist, - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) } // if (state is LibraryArtistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/400x400") - } + items(4) { + SkeletonTree(true) { + AvatarCard( + title = "Artist Name", + subtitle = "Artist", + imageURL = "https://placehold.co/400x400" + ) } + } // } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt index 8d8b2cbb..fa5556ce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.library.LibraryRepository +import dev.krtirtho.spotube.modules.library.LibraryState +import dev.krtirtho.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -46,7 +49,8 @@ sealed interface LibraryArtistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -59,13 +63,15 @@ sealed interface LibraryArtistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination) + nextPagination = nextPagination + ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -82,11 +88,21 @@ sealed interface LibraryArtistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryArtistsViewModel( - private val repository: LibraryRepository) : ViewModel(), KoinComponent { + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryArtistsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Artists) + ) { state, query -> + when (state) { + is LibraryArtistsState.Data.Loaded -> state.copy(query = query) + is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading) init { viewModelScope.launch { @@ -111,7 +127,8 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri) + externalUri = it.externalUri + ) } ?: emptyList(), nextPagination = result?.nextPagination) }.onFailure { e -> @@ -129,12 +146,14 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri) + externalUri = it.externalUri + ) } ?: emptyList() _state.value = LibraryArtistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + newItems, - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) } }.onFailure { e -> logger.e(e) { "Failed to load more artists" } @@ -142,13 +161,7 @@ class LibraryArtistsViewModel( } fun onQueryChange(query: String) { - val currentState = _state.value - if (currentState is LibraryArtistsState.Data) { - _state.value = when (currentState) { - is LibraryArtistsState.Data.Loaded -> currentState.copy(query = query) - is LibraryArtistsState.Data.LoadingMore -> currentState.copy(query = query) - } - } + libraryState.setQuery(LibraryTab.Artists, query) } fun refresh() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 31ce23ce..71e3c4f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -25,8 +25,6 @@ import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumListTile -import org.spotube.millennium.composables.MillenniumTextField -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,7 +36,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.input.rememberTextFieldState import com.composeunstyled.Text @@ -46,28 +43,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.drop import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.Play import org.koin.compose.viewmodel.koinViewModel @Composable -fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) { +fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() val permissionState = rememberLocalMediaPermissionState() @@ -85,53 +78,25 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } - val searchState = rememberTextFieldState(state.query) - LaunchedEffect(searchState) { - snapshotFlow { searchState.text.toString() } - .drop(1) - .collect(viewModel::onQueryChange) - } Box(modifier = modifier.fillMaxWidth()) { Column( modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - verticalArrangement = Arrangement.spacedBy(12.dp)) { - AnimatedVisibility(searchMode) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MillenniumIcon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search") - MillenniumTextField( - state = searchState, - - modifier = Modifier.weight(1f), - placeholder = if (state.currentFolderPath == null) { - "Search folders or tracks" - } else { - "Search tracks in folder" - }) - MillenniumIconButton( - icon = Iconsax.IconsaxRefreshRight, - contentDescription = "Refresh", - onClick = { viewModel.refresh() }, - style = MillenniumIconButtonStyle.Quiet) - } - } - + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { if (state.currentFolderPath != null) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { + horizontalArrangement = Arrangement.SpaceBetween + ) { Row(verticalAlignment = Alignment.CenterVertically) { MillenniumIconButton( icon = Iconsax.ArrowLeft3, contentDescription = "Back", onClick = viewModel::navigateUp, - style = MillenniumIconButtonStyle.Quiet) + style = MillenniumIconButtonStyle.Quiet + ) Text(viewModel.currentFolder()?.name ?: "Folder") } MillenniumIconButton( @@ -140,7 +105,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean onClick = { viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } }, - style = MillenniumIconButtonStyle.Quiet) + style = MillenniumIconButtonStyle.Quiet + ) } } @@ -151,11 +117,13 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp)) { + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { Text("Allow audio permission to show local tracks") MillenniumButton( onClick = permissionState.requestPermission, - style = MillenniumButtonStyle.Quiet) { + style = MillenniumButtonStyle.Quiet + ) { Text("Grant access") } } @@ -212,7 +180,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean icon = Iconsax.Play, contentDescription = "Play", onClick = { viewModel.playTrack(track.path) }, - style = MillenniumIconButtonStyle.Quiet) + style = MillenniumIconButtonStyle.Quiet + ) }, onClick = { viewModel.playTrack(track.path) }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index d1897a74..63a0d18d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -21,13 +21,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.modules.library.LibraryState +import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch data class LibraryLocalTracksState( @@ -35,24 +39,34 @@ data class LibraryLocalTracksState( val folders: List = emptyList(), val currentFolderPath: String? = null, val query: String = "", - val error: String? = null) + val error: String? = null +) class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, - private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { + private val audioPlayerQueue: AudioPlayerQueue, + private val libraryState: LibraryState +) : ViewModel() { private val _state = MutableStateFlow(LibraryLocalTracksState()) - val state: StateFlow = _state.asStateFlow() + val state: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.LocalTracks) + ) { state, query -> state.copy(query = query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState()) init { + viewModelScope.launch { + libraryState.localRefreshRequests.collect { refresh() } + } viewModelScope.launch { coordinator.cacheState .collect { cache -> - _state.update { - it.copy( - folders = cache.folders, - error = null) + _state.update { + it.copy( + folders = cache.folders, + error = null + ) + } } - } } refreshIfEmpty() @@ -60,7 +74,7 @@ class LibraryLocalTracksViewModel( } fun onQueryChange(query: String) { - _state.update { it.copy(query = query) } + libraryState.setQuery(LibraryTab.LocalTracks, query) } fun openFolder(folderPath: String) { @@ -87,7 +101,7 @@ class LibraryLocalTracksViewModel( fun playFolder(folderPath: String) { val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return - val tracks = filterTracks(folder.tracks, _state.value.query) + val tracks = filterTracks(folder.tracks, state.value.query) if (tracks.isEmpty()) return viewModelScope.launch { @@ -95,13 +109,14 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = 0, - collectionEntry = null) + collectionEntry = null + ) } } fun playTrack(trackPath: String) { val folder = currentFolder() ?: return - val tracks = filterTracks(folder.tracks, _state.value.query) + val tracks = filterTracks(folder.tracks, state.value.query) val selectedIndex = tracks.indexOfFirst { it.path == trackPath } if (selectedIndex < 0) return @@ -110,7 +125,8 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = selectedIndex, - collectionEntry = null) + collectionEntry = null + ) } } @@ -120,10 +136,10 @@ class LibraryLocalTracksViewModel( return state.value.folders.filter { folder -> folder.name.contains(query, ignoreCase = true) || - folder.tracks.any { track -> - track.name.contains(query, ignoreCase = true) || - track.artists.any { it.contains(query, ignoreCase = true) } - } + folder.tracks.any { track -> + track.name.contains(query, ignoreCase = true) || + track.artists.any { it.contains(query, ignoreCase = true) } + } } } @@ -156,8 +172,8 @@ class LibraryLocalTracksViewModel( return tracks.filter { track -> track.name.contains(normalizedQuery, ignoreCase = true) || - track.album?.contains(normalizedQuery, ignoreCase = true) == true || - track.artists.any { it.contains(normalizedQuery, ignoreCase = true) } + track.album?.contains(normalizedQuery, ignoreCase = true) == true || + track.artists.any { it.contains(normalizedQuery, ignoreCase = true) } } } @@ -168,6 +184,7 @@ class LibraryLocalTracksViewModel( duration = durationMs, album = album, coverBytes = coverBytes, - url = path) + url = path + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 45247b53..e5d41549 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -18,68 +18,45 @@ package dev.krtirtho.spotube.modules.library.playlist -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumTextField -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState - -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @Composable fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, - searchMode: Boolean = false, - viewModel: LibraryPlaylistsViewModel = koinViewModel()) { + showCreatePlaylist: Boolean = false, + onDismissCreatePlaylist: () -> Unit = {}, + viewModel: LibraryPlaylistsViewModel = koinViewModel() +) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() - var showCreatePlaylist by remember { mutableStateOf(false) } - - val searchState = rememberTextFieldState((state as? LibraryPlaylistsState.Data)?.query ?: "") - LaunchedEffect(searchState) { - snapshotFlow { searchState.text.toString() } - .drop(1) - .collect(viewModel::onQueryChange) - } LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } @@ -101,26 +78,6 @@ fun LibraryPlaylistsScreen( } Column(modifier = modifier) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically) { - AnimatedVisibility( - visible = searchMode, - modifier = Modifier.weight(1f)) { - MillenniumTextField( - state = searchState, - - modifier = Modifier.fillMaxWidth(), - placeholder = "Search saved playlists...") - } - MillenniumIconButton( - icon = Iconsax.IconsaxAddSquare, - contentDescription = "Create Playlist", - onClick = { showCreatePlaylist = true }, - modifier = Modifier.size(48.dp)) - } - Spacer(modifier = Modifier.height(12.dp)) when (state) { is LibraryPlaylistsState.Loading -> { val shellBottomInset = LocalAppShellBottomInset.current @@ -139,7 +96,8 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -195,7 +153,8 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -206,7 +165,7 @@ fun LibraryPlaylistsScreen( PlaylistFormSheet( visible = showCreatePlaylist, - onDismiss = { showCreatePlaylist = false }, + onDismiss = onDismissCreatePlaylist, onSubmit = { data -> viewModel.createPlaylist( name = data.name, @@ -214,7 +173,8 @@ fun LibraryPlaylistsScreen( isPublic = data.isPublic, isCollaborating = data.isCollaborating, imageBase64 = data.imageBase64, - trackIds = emptyList()) + trackIds = emptyList() + ) }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index efceaded..0072a4ac 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStra import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.library.LibraryRepository +import dev.krtirtho.spotube.modules.library.LibraryState +import dev.krtirtho.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -46,12 +49,13 @@ sealed interface LibraryPlaylistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) + (it.description?.contains(query, ignoreCase = true) == true) } } else { allItems @@ -60,18 +64,20 @@ sealed interface LibraryPlaylistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination) + nextPagination = nextPagination + ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) + (it.description?.contains(query, ignoreCase = true) == true) } } else { allItems @@ -84,11 +90,21 @@ sealed interface LibraryPlaylistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryPlaylistsViewModel( - private val repository: LibraryRepository) : ViewModel(), KoinComponent { + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryPlaylistsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Playlists) + ) { state, query -> + when (state) { + is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query) + is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading) init { viewModelScope.launch { @@ -109,7 +125,8 @@ class LibraryPlaylistsViewModel( val result = repository.savedPlaylists() _state.value = LibraryPlaylistsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) }.onFailure { e -> logger.e(e) { "Failed to load playlists" } _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") @@ -123,7 +140,8 @@ class LibraryPlaylistsViewModel( _state.value = LibraryPlaylistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) } }.onFailure { e -> logger.e(e) { "Failed to load more playlists" } @@ -131,13 +149,7 @@ class LibraryPlaylistsViewModel( } fun onQueryChange(query: String) { - val currentState = _state.value - if (currentState is LibraryPlaylistsState.Data) { - _state.value = when (currentState) { - is LibraryPlaylistsState.Data.Loaded -> currentState.copy(query = query) - is LibraryPlaylistsState.Data.LoadingMore -> currentState.copy(query = query) - } - } + libraryState.setQuery(LibraryTab.Playlists, query) } fun refresh() { @@ -153,7 +165,8 @@ class LibraryPlaylistsViewModel( isPublic: Boolean, isCollaborating: Boolean, imageBase64: String, - trackIds: List) { + trackIds: List + ) { viewModelScope.launch { runCatching { repository.createPlaylist( @@ -162,7 +175,8 @@ class LibraryPlaylistsViewModel( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds) + trackIds = trackIds + ) }.onFailure { e -> logger.e(e) { "Failed to create playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index ff720d6c..8db81996 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -45,10 +45,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -77,12 +80,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.currentWindowWidthBreakpoint import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import dev.krtirtho.spotube.Platform import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.jam.JamRole @@ -103,6 +108,8 @@ import dev.krtirtho.spotube.core.ui.component.UserCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree +import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.isDesktop import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository @@ -121,6 +128,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.adaptive.WindowBreakpoints private val GridMinCellSize = 180.dp @@ -153,6 +161,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { emptyList() ) + val isExpanded = + currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop() + fun isTrackBlacklisted(track: MetadataTrack): Boolean { val trackIds = blacklistedTracks.map { it.id }.toSet() val artistIds = blacklistedArtists.map { it.id }.toSet() @@ -287,8 +298,25 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { modifier = Modifier .fillMaxSize() .background(MillenniumTheme.colors.background) + .windowInsetsPadding(WindowInsets.statusBars) ) { - ApplicationMainBar(backButton = false) + if (!isExpanded) { + SearchBar( + query = state.query, + onQueryChange = viewModel::onQueryChange, + recentSearchSuggestions = state.recentSearchSuggestions, + onRecentSearchClick = { search -> + viewModel.applyRecentSearch(search) + focusManager.clearFocus() + }, + onClearAllRecentSearches = { + viewModel.clearAllRecentSearches() + focusManager.clearFocus() + }, + ) + } else { + ApplicationMainBar(backButton = false) + } Column( modifier = Modifier .fillMaxWidth() @@ -297,18 +325,21 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { Column( modifier = Modifier.fillMaxWidth() ) { - SearchBar( - query = state.query, - onQueryChange = viewModel::onQueryChange, - recentSearchSuggestions = state.recentSearchSuggestions, - onRecentSearchClick = { search -> - viewModel.applyRecentSearch(search) - focusManager.clearFocus() - }, - onClearAllRecentSearches = { - viewModel.clearAllRecentSearches() - focusManager.clearFocus() - }) + if (isExpanded) { + SearchBar( + query = state.query, + onQueryChange = viewModel::onQueryChange, + recentSearchSuggestions = state.recentSearchSuggestions, + onRecentSearchClick = { search -> + viewModel.applyRecentSearch(search) + focusManager.clearFocus() + }, + onClearAllRecentSearches = { + viewModel.clearAllRecentSearches() + focusManager.clearFocus() + } + ) + } if (state.supportedSearchTypes.isNotEmpty()) { SearchTabs( diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt new file mode 100644 index 00000000..e5ce8585 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt @@ -0,0 +1,191 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.library + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme +import dev.krtirtho.spotube.modules.settings.Theme +import dev.krtirtho.spotube.modules.settings.UserSettings +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.spotube.millennium.composables.MillenniumTopAppBar +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryToolbarTest { + private lateinit var library: LibraryState + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + library = LibraryState() + } + + @AfterTest + fun cleanup() { + library.viewModelScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun closingSearchClearsTheActiveFilter() = runTest { + library.openSearch() + library.uiState.value.searchState.setTextAndPlaceCursorAtEnd("Muse") + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(library.uiState.value.searchMode) + assertEquals("Muse", library.queryFlow(LibraryTab.Playlists).first()) + + library.closeSearch() + Snapshot.sendApplyNotifications() + runCurrent() + assertFalse(library.uiState.value.searchMode) + assertEquals("", library.uiState.value.searchState.text.toString()) + } + + @Test + fun eachTabRetainsItsOwnFilterAndActions() = runTest { + library.setQuery(LibraryTab.Playlists, "Rock") + library.onTabSelected(LibraryTab.Albums) + library.setQuery(LibraryTab.Albums, "Jazz") + Snapshot.sendApplyNotifications() + runCurrent() + assertEquals("Jazz", library.uiState.value.searchState.text.toString()) + assertFalse(library.uiState.value.canCreatePlaylist) + + library.onTabSelected(LibraryTab.Playlists) + runCurrent() + assertEquals("Rock", library.uiState.value.searchState.text.toString()) + assertTrue(library.uiState.value.canCreatePlaylist) + library.showCreatePlaylist() + runCurrent() + assertTrue(library.uiState.value.showCreatePlaylist) + + library.onTabSelected(LibraryTab.LocalTracks) + runCurrent() + assertTrue(library.uiState.value.canRefresh) + assertFalse(library.uiState.value.showCreatePlaylist) + library.onTabSelected(LibraryTab.Downloads) + runCurrent() + assertFalse(library.uiState.value.canFilter) + assertFalse(library.uiState.value.searchMode) + } + + @Test + fun anInlineDesktopQueryRemainsVisibleWhenTheLayoutBecomesCompact() = runTest { + library.setQuery(LibraryTab.Artists, "Radiohead") + library.onTabSelected(LibraryTab.Artists) + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(library.uiState.value.searchMode) + assertEquals("Radiohead", library.uiState.value.searchState.text.toString()) + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderExpandedCompactAndSearchToolbars() { + val output = File("build/reports/library-toolbar").apply { mkdirs() } + for (tab in listOf(LibraryTab.Playlists, LibraryTab.Albums, LibraryTab.LocalTracks)) { + for ((mode, width) in listOf( + "desktop" to 575, + "mobile" to 360, + "search" to 360, + "compact-desktop" to 392, + "compact-desktop-search" to 392 + )) { + val expandedDesktop = mode == "desktop" + val searching = mode.contains("search") + val state = LibraryToolbarUiState( + currentTab = tab, + searchMode = searching, + searchState = TextFieldState(if (searching) "Muse" else ""), + canCreatePlaylist = tab == LibraryTab.Playlists, + canRefresh = tab == LibraryTab.LocalTracks + ) + ImageComposeScene(width, 88) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + MillenniumTopAppBar( + title = { LibraryToolbarTitle(state, expandedDesktop) }, + actions = { + LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {}) + if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer( + Modifier.width(132.dp) + ) + } + ) + } + }.use { scene -> + repeat(8) { scene.render().close() } + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + + val nodes = + scene.semanticsOwners.flatMap { flatten(it.unmergedRootSemanticsNode) } + val descriptions = nodes.flatMap { + it.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty() + } + val textFields = + nodes.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null } + assertEquals(if (expandedDesktop || searching) 1 else 0, textFields.size) + if (searching) { + assertTrue("Close filter" in descriptions) + assertFalse("Create playlist" in descriptions) + assertFalse("Refresh local tracks" in descriptions) + assertEquals( + "Muse", + textFields.single().config[SemanticsProperties.EditableText].text + ) + } else { + assertEquals(tab == LibraryTab.Playlists, "Create playlist" in descriptions) + assertEquals( + tab == LibraryTab.LocalTracks, + "Refresh local tracks" in descriptions + ) + } + textFields.forEach { node -> + assertTrue( + node.boundsInRoot.left >= 0f && node.boundsInRoot.right <= width, + "Filter must fit beside desktop window controls" + ) + } + scene.render().use { image -> + assertNotNull(image.encodeToData()).use { + File( + output, + "${tab.name}-$mode.png" + ).writeBytes(it.bytes) + } + } + } + } + } + } +} diff --git a/gradle.properties b/gradle.properties index e2904b99..a49b0977 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,9 +19,10 @@ kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M kotlin.mpp.stability.nowarn=true +kotlin.incremental=true #Gradle -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g org.gradle.configuration-cache=true org.gradle.caching=true @@ -32,3 +33,5 @@ android.useAndroidX=true android.builtInKotlin=false android.newDsl=false android.sourceset.disallowProvider=false + +org.gradle.vfs.watch=true \ No newline at end of file From 58dfa2de9208702f51e6e8e39d922d1d563d894f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Oct 2026 11:36:39 +0600 Subject: [PATCH 048/132] feat(library): add Library screen list mode beside grid mode, Local Track, Download screen, Local Library folders dialog visual improvement --- .../composeResources/values/strings.xml | 66 ++- .../dev/krtirtho/spotube/core/di/Modules.kt | 8 + .../spotube/core/ui/component/AlbumCard.kt | 19 +- .../spotube/core/ui/component/ArtistCard.kt | 36 +- .../core/ui/component/LikedTracksCard.kt | 86 ++-- .../spotube/core/ui/component/PlaylistCard.kt | 14 +- .../core/ui/component/cards/AvatarCard.kt | 63 ++- .../core/ui/component/cards/AvatarListTile.kt | 31 ++ .../ui/component/cards/MediaCardSurface.kt | 84 ++++ .../ui/component/cards/MediaEmptyState.kt | 66 +++ .../core/ui/component/cards/PlayableCard.kt | 78 ++-- .../ui/component/cards/PlayableListTile.kt | 175 ++++++++ .../modules/downloads/DownloadsScreen.kt | 413 +++++++++-------- .../modules/downloads/DownloadsViewModel.kt | 55 ++- .../modules/library/LibraryMediaCollection.kt | 79 ++++ .../spotube/modules/library/LibraryScreen.kt | 88 +++- .../spotube/modules/library/LibraryState.kt | 29 +- .../library/album/LibraryAlbumsScreen.kt | 180 +++----- .../library/album/LibraryAlbumsViewModel.kt | 44 +- .../library/artist/LibraryArtistsScreen.kt | 178 ++------ .../library/artist/LibraryArtistsViewModel.kt | 32 +- .../local_tracks/LibraryLocalTracksScreen.kt | 404 ++++++++++------- .../LibraryLocalTracksViewModel.kt | 134 ++++-- .../playlist/LibraryPlaylistsScreen.kt | 184 +++----- .../playlist/LibraryPlaylistsViewModel.kt | 41 +- .../library/playlist/PlaylistFormSheet.kt | 83 ++-- .../settings/LocalMediaFoldersViewModel.kt | 128 ++++++ .../components/LocalMediaFoldersEditor.kt | 257 +++++++++++ .../settings/sections/DownloadsSection.kt | 207 +++------ .../modules/library/LibraryToolbarTest.kt | 45 +- .../modules/library/MediaLibraryUiTest.kt | 415 ++++++++++++++++++ .../settings/LocalMediaFoldersEditorTest.kt | 217 +++++++++ 32 files changed, 2757 insertions(+), 1182 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt create mode 100644 composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 373fc2e4..6de1648b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -27,6 +27,7 @@ Your Library + Library Playlists Albums Artists @@ -40,6 +41,59 @@ Close filter Create playlist Refresh local tracks + Switch to grid view + Switch to list view + No saved playlists + No playlists match “%1$s” + No saved albums + No albums match “%1$s” + No saved artists + No artists match “%1$s” + + + Folders + Folder + %1$s • %2$s + + %1$d track + %1$d tracks + + + %1$d folder + %1$d folders + + Back to folders + Play folder + Audio access required + Allow audio access to browse music on your device. + Grant access + No local folders found + No tracks in this folder + No matching folders or tracks + Failed to refresh local media + + + %1$d active • %2$d completed + Clear completed + No downloads yet + Downloading %1$d% + Queued + Completed + Cancelled + Failed + Failed: %1$s + Cancel download + Retry download + Remove download + + + Artist + Blacklisted + Liked Tracks + Your saved songs + Unknown artist + Track title + Artist name Selected @@ -122,11 +176,21 @@ Local media folders Custom folders to scan on desktop. Added: %1$d No custom folder added yet - Use the picker to add folders. Download folder override is included automatically. + Custom folders for your local music library. Manage Add folder Remove folder No custom folders added. + Remove %1$s from the scan list + Download and music cache folders are included automatically. + Your download folder is included automatically. + The music cache folder is included automatically. + Saving… + Couldn’t save folders. Your changes are still here; try again. + + %1$d folder + %1$d folders + Download music format Preferred codec and container for downloads. Current: %1$s Download music quality diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index a8251a64..769c08ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -82,6 +82,7 @@ import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettingsSource import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -142,6 +143,13 @@ val sharedModules = module { singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) + viewModel { + val repository = get() + LocalMediaFoldersViewModel(repository.userSettings) { folders -> + val current = repository.getCurrentSettings() + repository.updateSettings(current.copy(localMediaFolders = folders)) + } + } // Welcome / Onboarding singleOf(::WelcomeRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 23599f4e..df7145a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -40,12 +40,15 @@ fun AlbumCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject()) { + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false +) { val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( title = album.title, - subtitle = album.description ?: "${album.albumType} • ${album.artists.joinToString { it.name }}", + subtitle = album.description + ?: "${album.albumType} • ${album.artists.joinToString { it.name }}", imageURL = album.thumbnails.firstOrNull()?.url, isPlaying = currentCollectionEntry?.id == album.id, onClick = { @@ -56,14 +59,18 @@ fun AlbumCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Album, album.id, - album.title) + album.title + ) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Album, album.id, - album.title) + album.title + ) }, - modifier = modifier.width(160.dp), - sharedElementKey = "album_art_${album.id}") + modifier = if (listMode) modifier else modifier.width(160.dp), + sharedElementKey = "album_art_${album.id}", + listMode = listMode + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt index 541facd1..0c9d065c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt @@ -17,52 +17,34 @@ package dev.krtirtho.spotube.core.ui.component -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.Alignment -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun ArtistCard( artist: MetadataArtist.Basic, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false) { + isBlacklisted: Boolean = false, + listMode: Boolean = false +) { val navigationCommands = koinInject() AvatarCard( title = artist.name, - subtitle = if (isBlacklisted) "Blacklisted" else "Artist", + subtitle = stringResource(if (isBlacklisted) Res.string.media_blacklisted else Res.string.media_artist), imageURL = artist.thumbnails.firstOrNull()?.url, onClick = { navigationCommands.navigateTo(Routes.Artist(artist.id)) }, modifier = modifier, - isBlacklisted = isBlacklisted) + isBlacklisted = isBlacklisted, + listMode = listMode + ) } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt index ac3444e8..456de393 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt @@ -21,14 +21,7 @@ package dev.krtirtho.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -44,7 +37,6 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,19 +50,21 @@ import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle import dev.krtirtho.spotube.resources.iconsax.Play import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.liked_tracks +import spotube.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource +import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile +import dev.krtirtho.spotube.core.ui.component.cards.MediaCardSurface +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -78,27 +72,49 @@ fun LikedTracksCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject()) { + navigationCommands: NavigationCommands = koinInject(), + listMode: Boolean = false +) { val scope = rememberCoroutineScope() - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks + val title = stringResource(Res.string.media_liked_tracks) + val subtitle = stringResource(Res.string.media_saved_songs) + if (listMode) { + PlayableListTile( + title = title, + subtitle = subtitle, + isPlaying = isPlaying, + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, + onPlay = { scope.launch { playbackHelper.playSavedTracks() } }, + onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, + modifier = modifier, + sharedElementKey = "saved_tracks_art", + artwork = { + Image( + painterResource(Res.drawable.liked_tracks), + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) - .width(160.dp) - .hoverable(interactionSource = interactionSource)) { + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) } + ) { isHovered -> Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) { androidx.compose.foundation.Image( painter = painterResource(Res.drawable.liked_tracks), - contentDescription = "Liked Tracks", + contentDescription = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) @@ -106,14 +122,16 @@ fun LikedTracksCard( .sharedElementOrNone( key = "saved_tracks_art", sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope), - contentScale = ContentScale.Crop) + animatedVisibilityScope = animatedVisibilityScope + ), + contentScale = ContentScale.Crop + ) Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), contentAlignment = Alignment.BottomEnd ) { - if (isHovered) { + if (isHovered || touchInput || isPlaying) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) @@ -125,7 +143,7 @@ fun LikedTracksCard( ) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue", + contentDescription = stringResource(Res.string.player_add_to_queue_content_description), modifier = Modifier.size(16.dp) ) } @@ -139,7 +157,7 @@ fun LikedTracksCard( imageVector = if (isPlaying) { Iconsax.IconsaxPauseCircle } else Iconsax.Play, - contentDescription = "Play", + contentDescription = stringResource(Res.string.player_play_content_description), modifier = Modifier.size(16.dp) ) } @@ -151,17 +169,19 @@ fun LikedTracksCard( modifier = Modifier.fillMaxWidth().padding(12.dp) ) { Text( - text = "Liked Tracks", + text = title, style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Your saved songs", + text = subtitle, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index ffacbb4d..48e82d51 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -40,7 +40,9 @@ fun PlaylistCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject()) { + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false +) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -57,14 +59,18 @@ fun PlaylistCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Playlist, playlist.id, - playlist.title) + playlist.title + ) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Playlist, playlist.id, - playlist.title) + playlist.title + ) }, modifier = modifier, - sharedElementKey = "playlist_art_${playlist.id}") + sharedElementKey = "playlist_art_${playlist.id}", + listMode = listMode + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt index ff08d418..db311800 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt @@ -17,79 +17,67 @@ package dev.krtirtho.spotube.core.ui.component.cards -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable 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.layout.ContentScale -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import org.koin.compose.koinInject +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.User @Composable fun AvatarCard( title: String, subtitle: String? = null, imageURL: String? = null, - onClick: () -> Unit = {}, + onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false) { - val backgroundColor = if (isBlacklisted) { - AppTheme.colors.error.copy(alpha = 0.1f) - } else { - Color.Transparent + isBlacklisted: Boolean = false, + listMode: Boolean = false +) { + if (listMode) { + AvatarListTile(title, subtitle, imageURL, onClick, modifier, isBlacklisted) + return } - - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .background(backgroundColor) - .clickable(onClick = { onClick.invoke() }, enabled = true) - .width(160.dp)) { + + MediaCardSurface(modifier.width(160.dp), onClick, isBlacklisted = isBlacklisted) { Column( modifier = Modifier .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally) { + horizontalAlignment = Alignment.CenterHorizontally + ) { Box( modifier = Modifier .fillMaxWidth() .clip(CircleShape) .padding(12.dp), - contentAlignment = Alignment.Center) { - AsyncImage( + contentAlignment = Alignment.Center + ) { + MediaArtwork( model = imageURL, - contentDescription = title, + title = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop) + shape = CircleShape, + placeholderIcon = Iconsax.User + ) } Column( modifier = Modifier.fillMaxWidth().padding(12.dp) @@ -99,17 +87,20 @@ fun AvatarCard( style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) subtitle?.let { + Spacer(Modifier.height(4.dp)) TextWithShimmer( text = subtitle, - style = AppTheme.typography.bodyMedium, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) } } } } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt new file mode 100644 index 00000000..b4095530 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt @@ -0,0 +1,31 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.User + +@Composable +fun AvatarListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isBlacklisted: Boolean = false +) { + PlayableListTile( + title = title, + subtitle = subtitle, + imageURL = imageURL, + onClick = onClick, + modifier = modifier, + imageShape = CircleShape, + placeholderIcon = Iconsax.User, + isBlacklisted = isBlacklisted + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt new file mode 100644 index 00000000..43809d5f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt @@ -0,0 +1,84 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumActionPointer +import org.spotube.millennium.theme.millenniumSurface + +/** Interaction state is presentation-only; all navigation/playback stays in event callbacks. */ +@Composable +internal fun MediaCardSurface( + modifier: Modifier, + onClick: (() -> Unit)?, + interactive: Boolean = onClick != null, + isBlacklisted: Boolean = false, + content: @Composable (highlighted: Boolean) -> Unit +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val pressed by interaction.collectIsPressedAsState() + val focused by interaction.collectIsFocusedAsState() + var groupFocused by remember { mutableStateOf(false) } + val colors = MillenniumTheme.colors + val highlight by animateFloatAsState( + if (hovered || focused || groupFocused || pressed) 1f else 0f, + tween(140) + ) + val scale by animateFloatAsState(if (pressed) 0.985f else 1f, tween(100)) + val shape = RoundedCornerShape(10.dp) + Box( + modifier.graphicsLayer { scaleX = scale; scaleY = scale } + .millenniumSurface( + background = Brush.verticalGradient( + listOf( + colors.raised.copy(alpha = highlight), + if (isBlacklisted) colors.danger.copy(alpha = 0.12f + highlight * 0.12f) + else colors.surface.copy(alpha = highlight) + ) + ), + shape = shape, + borderColor = (if (focused) colors.accent else colors.border).copy(alpha = highlight), + borderWidth = if (focused) 2.dp else 1.dp, + innerGlowColor = colors.highlight.copy(alpha = highlight * 0.4f), + shadowElevation = (highlight * 4f).dp + ) + .clip(shape) + .onFocusChanged { groupFocused = it.hasFocus } + .focusGroup() + .hoverable(interaction, enabled = interactive) + .then( + if (onClick != null) Modifier.millenniumActionPointer().clickable( + interactionSource = interaction, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) else Modifier + ) + ) { content(hovered || focused || groupFocused) } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt new file mode 100644 index 00000000..7cd95441 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface + +@Composable +internal fun MediaEmptyState( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + description: String? = null, + action: (@Composable () -> Unit)? = null +) { + Box(modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column( + Modifier.widthIn(max = 360.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + Modifier.size(64.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + icon, + null, + modifier = Modifier.size(30.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } + Text(title, style = AppTheme.typography.titleMedium, textAlign = TextAlign.Center) + if (description != null) Text( + description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + action?.invoke() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt index a4674b27..05edce14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt @@ -25,10 +25,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -38,21 +34,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone @@ -67,6 +60,8 @@ import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_add_to_queue_content_description import spotube.composeapp.generated.resources.player_pause_content_description import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.media_preview_title +import spotube.composeapp.generated.resources.media_preview_subtitle @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -79,22 +74,37 @@ fun PlayableCard( onPlay: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, - sharedElementKey: String? = null) { - val interactionSource = remember { MutableInteractionSource() } + sharedElementKey: String? = null, + listMode: Boolean = false +) { + if (listMode) { + PlayableListTile( + title, + subtitle, + imageURL, + isPlaying, + onClick, + onPlay, + onAddToQueue, + modifier, + sharedElementKey + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = { onClick?.invoke() }, enabled = true) - .width(160.dp) - .hoverable(interactionSource = interactionSource)) { + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = onClick ?: onPlay, + interactive = onClick != null || onPlay != null || onAddToQueue != null + ) { highlighted -> Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { - AsyncImage( + MediaArtwork( model = imageURL, - contentDescription = title, + title = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) @@ -102,19 +112,21 @@ fun PlayableCard( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope), - contentScale = ContentScale.Crop) + animatedVisibilityScope = animatedVisibilityScope + ), + shape = RoundedCornerShape(8.dp) + ) if (onPlay != null || onAddToQueue != null) Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), contentAlignment = Alignment.BottomEnd ) { - val isHovered by interactionSource.collectIsHoveredAsState() this@Column.AnimatedVisibility( - visible = isHovered || (isPlaying && onPlay != null), + visible = highlighted || touchInput || (isPlaying && onPlay != null), enter = fadeIn(), - exit = fadeOut()) { + exit = fadeOut() + ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) @@ -130,7 +142,9 @@ fun PlayableCard( MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, contentDescription = stringResource( - Res.string.player_add_to_queue_content_description)) + Res.string.player_add_to_queue_content_description + ) + ) } Spacer(modifier = Modifier.height(8.dp)) } @@ -150,7 +164,9 @@ fun PlayableCard( Res.string.player_pause_content_description } else { Res.string.player_play_content_description - })) + } + ) + ) } } } @@ -164,7 +180,8 @@ fun PlayableCard( text = title, style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) Spacer(modifier = Modifier.height(4.dp)) subtitle?.let { TextWithShimmer( @@ -172,7 +189,8 @@ fun PlayableCard( style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) } } } @@ -185,8 +203,8 @@ fun PlayableCard( private fun PlayableCardPreview() { Box { PlayableCard( - title = "Sample Title", - subtitle = "Sample Subtitle", + title = stringResource(Res.string.media_preview_title), + subtitle = stringResource(Res.string.media_preview_subtitle), imageURL = "https://placehold.co/600x400", isPlaying = true, onClick = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt new file mode 100644 index 00000000..253f2155 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt @@ -0,0 +1,175 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope +import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope +import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone +import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer +import dev.krtirtho.spotube.core.ui.misc.shimmerApply +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PlayableListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + isPlaying: Boolean = false, + onClick: (() -> Unit)? = null, + onPlay: (() -> Unit)? = null, + onAddToQueue: (() -> Unit)? = null, + modifier: Modifier = Modifier, + sharedElementKey: String? = null, + imageModel: Any? = imageURL, + imageShape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + isBlacklisted: Boolean = false +) { + val colors = MillenniumTheme.colors + MillenniumListTile( + modifier = modifier, + onClick = onClick ?: onPlay, + selected = isPlaying, + background = if (isPlaying) null else millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border, + titleForeground = if (isBlacklisted) colors.danger else null, + borderWidth = 1.dp, + contentPadding = PaddingValues(12.dp), + minimumHeight = 76.dp, + leading = { + MediaArtwork( + imageModel, title, + Modifier.size(52.dp).sharedElementOrNone( + sharedElementKey, + LocalSharedTransitionScope.current, + LocalAnimatedVisibilityScope.current + ), + imageShape, placeholderIcon, artwork + ) + }, + title = { + TextWithShimmer( + title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = subtitle?.let { text -> + { + TextWithShimmer( + text, + style = AppTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + }, + trailing = if (onPlay != null || onAddToQueue != null || trailingContent != null) { + { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + trailingContent?.invoke() + if (onAddToQueue != null && !isPlaying) { + MillenniumIconButton( + onClick = onAddToQueue, + modifier = Modifier.size(40.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + Iconsax.IconsaxAddSquare, + stringResource(Res.string.player_add_to_queue_content_description) + ) + } + } + if (onPlay != null) { + MillenniumIconButton( + onClick = onPlay, + modifier = Modifier.size(40.dp), + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + stringResource(if (isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description) + ) + } + } + } + } + } else null + ) +} + +@Composable +internal fun MediaArtwork( + model: Any?, + title: String, + modifier: Modifier, + shape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null +) { + Box( + modifier.millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = shape, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).clip(shape).shimmerApply(), + contentAlignment = Alignment.Center + ) { + if (artwork != null) artwork() + else if (model != null) AsyncImage( + model, + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + else MillenniumIcon( + placeholderIcon, + null, + modifier = Modifier.size(24.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt index b8cad5cf..df59bd4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt @@ -1,30 +1,8 @@ -/* - * 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 . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.downloads - -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumProgress -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,87 +16,153 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text - +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.crossfade +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork +import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* @Composable -fun DownloadsScreen(modifier: Modifier = Modifier) { - val viewModel = koinViewModel() - val downloads by viewModel.downloads.collectAsStateWithLifecycle() - - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 8.dp, bottom = 16.dp + shellBottomInset) +fun DownloadsScreen( + modifier: Modifier = Modifier, + viewModel: DownloadsViewModel = koinViewModel(), + headerInAppBar: Boolean = false, + topContent: @Composable () -> Unit = {} +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Column(modifier.fillMaxSize()) { + if (headerInAppBar) { + ApplicationMainBar( + backButton = false, + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, viewModel::clearCompleted) } + ) + topContent() + } + DownloadsContent( + state, + viewModel::cancel, + viewModel::retry, + viewModel::remove, + viewModel::clearCompleted, + Modifier.weight(1f) + .then(if (headerInAppBar) Modifier.padding(horizontal = 12.dp) else Modifier), + showHeader = !headerInAppBar + ) } +} - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter)) { - val hasCompleted = downloads.any { it.status == DownloadStatus.Completed } - if (hasCompleted) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End) { - MillenniumButton( - onClick = viewModel::clearCompleted, - style = MillenniumButtonStyle.Quiet) { - Row(verticalAlignment = Alignment.CenterVertically) { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - modifier = Modifier.size(16.dp)) - Text( - text = " Clear completed", - style = AppTheme.typography.labelLarge) - } - } +@Composable +internal fun DownloadsToolbarTitle() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(20.dp) + ) + Text( + stringResource(Res.string.library_tab_downloads), + style = AppTheme.typography.titleMedium + ) + } +} + +@Composable +internal fun DownloadsToolbarSummary(state: DownloadsUiState) { + if (state.items.isNotEmpty()) Text( + stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), + modifier = Modifier.padding(start = 28.dp), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) +} + +@Composable +internal fun DownloadsToolbarActions(state: DownloadsUiState, onClearCompleted: () -> Unit) { + if (state.canClearCompleted) MillenniumButton( + onClick = onClearCompleted, + style = MillenniumButtonStyle.Secondary + ) { + Text( + stringResource(Res.string.downloads_clear_completed), + style = AppTheme.typography.labelMedium + ) + } +} + +@Composable +internal fun DownloadsContent( + state: DownloadsUiState, + onCancel: (String) -> Unit, + onRetry: (String) -> Unit, + onRemove: (String) -> Unit, + onClearCompleted: () -> Unit, + modifier: Modifier = Modifier, + showHeader: Boolean = true +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column(Modifier.widthIn(max = 960.dp).fillMaxSize()) { + if (showHeader) Row( + Modifier.fillMaxWidth().padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(Modifier.weight(1f)) { + DownloadsToolbarTitle() + DownloadsToolbarSummary(state) } + DownloadsToolbarActions(state, onClearCompleted) } - - if (downloads.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center) { - Text( - text = "No downloads yet", - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) - } - } else { - LazyColumn( - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp)) { - items(downloads, key = { it.id }) { item -> - DownloadListRow( - item = item, - onCancel = { viewModel.cancel(item.id) }, - onRetry = { viewModel.retry(item.id) }, - onRemove = { viewModel.remove(item.id) }) - } + if (state.items.isEmpty()) MediaEmptyState( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.downloads_empty), + Modifier.padding(bottom = inset) + ) + else LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues( + top = if (showHeader) 0.dp else 16.dp, + bottom = inset + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.items, key = { it.id }) { row -> + DownloadListRow( + row, + { onCancel(row.id) }, + { onRetry(row.id) }, + { onRemove(row.id) }) } } } @@ -126,147 +170,82 @@ fun DownloadsScreen(modifier: Modifier = Modifier) { } @Composable -private fun DownloadListRow( - item: DownloadItem, +internal fun DownloadListRow( + item: DownloadRowUiState, onCancel: () -> Unit, onRetry: () -> Unit, - onRemove: () -> Unit) { - val rowBackgroundColor = when (item.status) { - is DownloadStatus.Failed -> AppTheme.colors.errorContainer.copy(alpha = 0.2f) - is DownloadStatus.Downloading -> AppTheme.colors.primaryContainer.copy(alpha = 0.15f) - else -> AppTheme.colors.surface - } - + onRemove: () -> Unit +) { + val colors = MillenniumTheme.colors Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clip(AppTheme.shapes.small) - .background(rowBackgroundColor) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp)) { - val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails) - ?.firstOrNull()?.url - - val platformContext = LocalPlatformContext.current - val imageRequest = remember(imageUrl) { - ImageRequest.Builder(platformContext) - .data(imageUrl) - .size(128) - .crossfade(false) - .build() - } - - Box( - modifier = Modifier - .size(44.dp) - .clip(AppTheme.shapes.small), - contentAlignment = Alignment.Center) { - AsyncImage( - model = imageRequest, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize()) - - if (item.status == DownloadStatus.Downloading) { - MillenniumProgress( - progress = item.progress, - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .align(Alignment.BottomCenter)) - } - } - - Column(modifier = Modifier.weight(1f)) { + Modifier.fillMaxWidth().millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = colors.border, + borderWidth = 1.dp, + innerGlowColor = colors.highlight.copy(alpha = 0.2f) + ).padding(12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MediaArtwork(item.artworkUrl, item.title, Modifier.size(52.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = item.title, - style = AppTheme.typography.bodyLarge, + item.title, + style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis) - + overflow = TextOverflow.Ellipsis + ) Text( - text = item.artists, + item.artists, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.primary, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) + Text( + when (item.status) { + DownloadStatus.Downloading -> stringResource( + Res.string.downloads_status_downloading, + item.percent + ) - when (item.status) { - is DownloadStatus.Downloading -> { - val percent = (item.progress * 100).toInt() - Text( - text = "Downloading... $percent%", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) - } - - is DownloadStatus.Queued -> { - Text( - text = "Queued", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) - } - - is DownloadStatus.Failed -> { - Text( - text = "Failed: ${item.errorMessage ?: "Unknown error"}", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.error, - maxLines = 1, - overflow = TextOverflow.Ellipsis) - } - - is DownloadStatus.Cancelled -> { - Text( - text = "Cancelled", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) - } - - is DownloadStatus.Completed -> { - Text( - text = "Completed", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.primary) - } - } + DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued) + DownloadStatus.Completed -> stringResource(Res.string.downloads_status_completed) + DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled) + is DownloadStatus.Failed -> if (item.error != null) stringResource( + Res.string.downloads_status_failed_error, + item.error + ) else stringResource(Res.string.downloads_status_failed) + }, + style = AppTheme.typography.labelSmall, + color = when (item.status) { + is DownloadStatus.Failed -> AppTheme.colors.error + DownloadStatus.Completed -> AppTheme.colors.primary + else -> AppTheme.colors.onSurfaceVariant + }, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + if (item.status == DownloadStatus.Downloading) MillenniumProgress( + item.progress, + Modifier.fillMaxWidth().height(4.dp) + ) } - - when (item.status) { - is DownloadStatus.Downloading, is DownloadStatus.Queued -> { - MillenniumIconButton( - onClick = onCancel, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Cancel", - tint = AppTheme.colors.onSurfaceVariant) - } - } - - is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { - MillenniumIconButton( - onClick = onRetry, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Retry", - tint = AppTheme.colors.primary) - } - } - - is DownloadStatus.Completed -> { - MillenniumIconButton( - onClick = onRemove, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Remove", - tint = AppTheme.colors.onSurfaceVariant) - } - } + MillenniumIconButton( + onClick = when (item.action) { + DownloadRowAction.Cancel -> onCancel; DownloadRowAction.Retry -> onRetry; DownloadRowAction.Remove -> onRemove + }, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + when (item.action) { + DownloadRowAction.Cancel -> Iconsax.IconsaxCloseSquare; DownloadRowAction.Retry -> Iconsax.IconsaxRefreshRight; DownloadRowAction.Remove -> Iconsax.IconsaxTrash + }, + stringResource( + when (item.action) { + DownloadRowAction.Cancel -> Res.string.downloads_action_cancel; DownloadRowAction.Retry -> Res.string.downloads_action_retry; DownloadRowAction.Remove -> Res.string.downloads_action_remove + } + ) + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt index b1aef155..a1b87a73 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt @@ -23,13 +23,64 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.map + +enum class DownloadRowAction { Cancel, Retry, Remove } + +data class DownloadRowUiState( + val id: String, + val title: String, + val artists: String, + val artworkUrl: String?, + val status: DownloadStatus, + val progress: Float, + val percent: Int, + val error: String?, + val action: DownloadRowAction +) + +data class DownloadsUiState( + val items: List = emptyList(), + val activeCount: Int = 0, + val completedCount: Int = 0, + val canClearCompleted: Boolean = false +) + +internal fun downloadsUiState(items: List): DownloadsUiState { + val completedCount = items.count { it.status == DownloadStatus.Completed } + return DownloadsUiState( + items = items.map { item -> + val progress = item.progress.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + DownloadRowUiState( + item.id, item.title, item.artists, + (item.track?.album?.thumbnails + ?: item.track?.thumbnails)?.firstOrNull()?.url?.takeIf { it.isNotBlank() }, + item.status, progress, (progress * 100).toInt(), + item.errorMessage ?: (item.status as? DownloadStatus.Failed)?.error, + when (item.status) { + DownloadStatus.Completed -> DownloadRowAction.Remove + is DownloadStatus.Failed, DownloadStatus.Cancelled -> DownloadRowAction.Retry + else -> DownloadRowAction.Cancel + } + ) + }, + activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, + completedCount = completedCount, + canClearCompleted = completedCount > 0 + ) +} class DownloadsViewModel( - private val downloadManager: DownloadManager) : ViewModel() { + private val downloadManager: DownloadManager +) : ViewModel() { val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), - emptyList()) + emptyList() + ) + + val uiState = downloadManager.downloadsFlow.map(::downloadsUiState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) fun downloadTrack(track: MetadataTrack) { downloadManager.enqueue(track) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt new file mode 100644 index 00000000..374ee5ab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt @@ -0,0 +1,79 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.misc.SkeletonTree +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import kotlinx.coroutines.flow.distinctUntilChanged + +/** Only reports viewport geometry. The owning ViewModel decides when to load another page. */ +@Composable +internal fun LibraryMediaCollection( + items: List, + itemKey: (T) -> String, + viewMode: LibraryViewMode, + onViewportChanged: (Int?, Int) -> Unit, + itemContent: @Composable (T, Boolean) -> Unit, + skeleton: @Composable (Boolean) -> Unit, + loading: Boolean = false, + loadingMore: Boolean = false, + header: (@Composable (Boolean) -> Unit)? = null, + modifier: Modifier = Modifier +) { + val gridState = rememberLazyGridState() + val listState = rememberLazyListState() + val reportViewport by rememberUpdatedState(onViewportChanged) + val padding = PaddingValues(top = 16.dp, bottom = LocalAppShellBottomInset.current + 16.dp) + LaunchedEffect(viewMode, gridState, listState) { + val positions = if (viewMode == LibraryViewMode.List) snapshotFlow { + listState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } else snapshotFlow { + gridState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } + positions.distinctUntilChanged().collect { (last, total) -> reportViewport(last, total) } + } + if (viewMode == LibraryViewMode.List) { + LazyColumn( + modifier.fillMaxSize(), state = listState, contentPadding = padding, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(true) } } + else { + if (header != null) item(key = "library_header") { header(true) } + items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } } + } + } + } else { + LazyVerticalGrid( + GridCells.Adaptive(160.dp), modifier.fillMaxSize(), state = gridState, + contentPadding = padding, horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(false) } } + else { + if (header != null) item(key = "library_header") { header(false) } + items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index de796a5b..4b7b24af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.input.TextFieldState @@ -38,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text import com.composeunstyled.currentWindowWidthBreakpoint @@ -63,6 +65,9 @@ import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumTextField +import compose.icons.FeatherIcons +import compose.icons.feathericons.Grid +import compose.icons.feathericons.List import spotube.composeapp.generated.resources.* @Composable @@ -70,13 +75,24 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { val state by libraryState.uiState.collectAsStateWithLifecycle() val expandedDesktop = getPlatform().isDesktop() && (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Expanded + val compactDesktop = getPlatform().isDesktop() && isCompact + val viewMode = state.viewModeOverride ?: defaultLibraryViewMode(isCompact) val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current + if (state.currentTab == LibraryTab.Downloads && !isCompact) { + DownloadsScreen( + headerInAppBar = true, + topContent = { if (!expandedDesktop) LibraryTabs(state, libraryState::onTabSelected) } + ) + return + } + Column(Modifier.fillMaxSize()) { ApplicationMainBar( backButton = false, - title = { LibraryToolbarTitle(state, expandedDesktop) }, + title = { LibraryToolbarTitle(state, expandedDesktop, compactDesktop) }, actions = { LibraryToolbarActions( state = state, @@ -88,32 +104,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { libraryState.closeSearch() }, onCreatePlaylist = libraryState::showCreatePlaylist, - onRefresh = libraryState::refreshLocalTracks + onRefresh = libraryState::refreshLocalTracks, + onToggleView = { libraryState.toggleViewMode(isCompact) }, + viewMode = viewMode, + compactDesktop = compactDesktop ) } ) if (!expandedDesktop) { - LazyRow( - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(LibraryTab.entries) { tab -> - MillenniumButton( - onClick = { libraryState.onTabSelected(tab) }, - style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary - ) { Text(stringResource(tab.titleResource)) } - } - } + LibraryTabs(state, libraryState::onTabSelected) } Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { when (state.currentTab) { LibraryTab.Playlists -> LibraryPlaylistsScreen( showCreatePlaylist = state.showCreatePlaylist, - onDismissCreatePlaylist = libraryState::dismissCreatePlaylist + onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, + viewMode = viewMode ) - LibraryTab.Albums -> LibraryAlbumsScreen() - LibraryTab.Artists -> LibraryArtistsScreen() + LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode) + LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode) LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.Downloads -> DownloadsScreen() } @@ -122,7 +132,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { } @Composable -internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) { +private fun LibraryTabs(state: LibraryToolbarUiState, onTabSelected: (LibraryTab) -> Unit) { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(LibraryTab.entries) { tab -> + MillenniumButton( + onClick = { onTabSelected(tab) }, + style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary + ) { Text(stringResource(tab.titleResource)) } + } + } +} + +@Composable +internal fun LibraryToolbarTitle( + state: LibraryToolbarUiState, + expandedDesktop: Boolean, + compactDesktop: Boolean = false +) { if (!expandedDesktop && state.searchMode) { LibraryFilterField( state.searchState, @@ -131,7 +160,11 @@ internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: autoFocus = true ) } else if (!expandedDesktop) { - Text(stringResource(Res.string.library_screen_title)) + Text( + stringResource(if (compactDesktop) Res.string.library_toolbar_compact_title else Res.string.library_screen_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } @@ -142,13 +175,18 @@ internal fun RowScope.LibraryToolbarActions( onOpenSearch: () -> Unit, onCloseSearch: () -> Unit, onCreatePlaylist: () -> Unit, - onRefresh: () -> Unit + onRefresh: () -> Unit, + onToggleView: () -> Unit = {}, + viewMode: LibraryViewMode = state.viewModeOverride ?: defaultLibraryViewMode(!expandedDesktop), + compactDesktop: Boolean = false ) { + val actionModifier = Modifier.size(if (compactDesktop) 32.dp else 40.dp) if (!expandedDesktop && state.searchMode) { MillenniumIconButton( icon = Iconsax.IconsaxCloseSquare, contentDescription = stringResource(Res.string.library_action_close_search), onClick = onCloseSearch, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } else { @@ -164,6 +202,7 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxSearchBroken, contentDescription = stringResource(Res.string.library_action_search), onClick = onOpenSearch, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } @@ -173,6 +212,7 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxAddSquare, contentDescription = stringResource(Res.string.library_action_create_playlist), onClick = onCreatePlaylist, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } @@ -181,6 +221,16 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxRefreshRight, contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), onClick = onRefresh, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + if (state.canChangeView) { + MillenniumIconButton( + icon = if (viewMode == LibraryViewMode.List) FeatherIcons.Grid else FeatherIcons.List, + contentDescription = stringResource(if (viewMode == LibraryViewMode.List) Res.string.library_action_grid_view else Res.string.library_action_list_view), + onClick = onToggleView, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt index 9002784f..4e7effbf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt @@ -31,6 +31,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +enum class LibraryViewMode { Grid, List } data class LibraryToolbarUiState( val currentTab: LibraryTab = LibraryTab.Playlists, @@ -39,7 +42,9 @@ data class LibraryToolbarUiState( val canFilter: Boolean = true, val canCreatePlaylist: Boolean = true, val canRefresh: Boolean = false, - val showCreatePlaylist: Boolean = false + val showCreatePlaylist: Boolean = false, + val canChangeView: Boolean = true, + val viewModeOverride: LibraryViewMode? = null ) /** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */ @@ -49,14 +54,16 @@ class LibraryState : ViewModel() { val currentTab = MutableStateFlow(LibraryTab.Playlists) private val searchStates = LibraryTab.entries.associateWith { TextFieldState() } private val createPlaylistVisible = MutableStateFlow(false) + private val viewModes = MutableStateFlow>(emptyMap()) private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) val localRefreshRequests = refreshRequests.asSharedFlow() val uiState = combine( currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, searchMode, - createPlaylistVisible - ) { (tab, query), searching, creating -> + createPlaylistVisible, + viewModes + ) { (tab, query), searching, creating, modes -> LibraryToolbarUiState( currentTab = tab, searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), @@ -64,7 +71,9 @@ class LibraryState : ViewModel() { canFilter = tab != LibraryTab.Downloads, canCreatePlaylist = tab == LibraryTab.Playlists, canRefresh = tab == LibraryTab.LocalTracks, - showCreatePlaylist = tab == LibraryTab.Playlists && creating + showCreatePlaylist = tab == LibraryTab.Playlists && creating, + canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, + viewModeOverride = modes[tab] ) }.stateIn( viewModelScope, SharingStarted.Eagerly, @@ -103,4 +112,16 @@ class LibraryState : ViewModel() { fun refreshLocalTracks() { refreshRequests.tryEmit(Unit) } + + fun toggleViewMode(isCompact: Boolean) { + val tab = currentTab.value + if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return + viewModes.update { modes -> + val current = modes[tab] ?: defaultLibraryViewMode(isCompact) + modes + (tab to if (current == LibraryViewMode.Grid) LibraryViewMode.List else LibraryViewMode.Grid) + } + } } + +internal fun defaultLibraryViewMode(isCompact: Boolean) = + if (isCompact) LibraryViewMode.List else LibraryViewMode.Grid diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index b422147f..7bc5bdaa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -1,158 +1,76 @@ -/* - * 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 . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.album - -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState - -import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import dev.krtirtho.spotube.modules.library.LibraryMediaCollection +import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* @Composable fun LibraryAlbumsScreen( modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryAlbumsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() + when (val current = state) { + is LibraryAlbumsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() + else -> { + val data = current as? LibraryAlbumsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryAlbumsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_albums_no_results, + data.query + ) + else stringResource(Res.string.library_albums_empty) + ) } - } - } - - Column(modifier = modifier) { - when (state) { - is LibraryAlbumsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - PlayableCard( - title = "Album Title", - subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } - - is LibraryAlbumsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryAlbumsState.Error).message, - onRetry = { viewModel.refresh() }) - } - } - - is LibraryAlbumsState.Data -> { - val dataState = state as LibraryAlbumsState.Data - if (dataState.items.isEmpty() && state !is LibraryAlbumsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotBlank() -> Text("No albums found for '${dataState.query}'") - else -> Text("No saved albums") - } - } - } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(dataState.items, key = { it.id }) { album -> - AlbumCard( - album = album, - modifier = Modifier.fillMaxWidth() - ) - } - - if (state is LibraryAlbumsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - PlayableCard( - title = "Album Title", - subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } - } - } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryAlbumsState.Loading, + loadingMore = current is LibraryAlbumsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + AlbumCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 60fd0b39..2f9de499 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -51,17 +51,16 @@ sealed interface LibraryAlbumsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { album -> - album.title.contains(query, ignoreCase = true) || - album.artists.any { artist -> - artist.name.contains(query, ignoreCase = true) - } - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, @@ -75,17 +74,16 @@ sealed interface LibraryAlbumsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { album -> - album.title.contains(query, ignoreCase = true) || - album.artists.any { artist -> - artist.name.contains(query, ignoreCase = true) - } - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } } + } else { + allItems + } } } @@ -156,6 +154,12 @@ class LibraryAlbumsViewModel( libraryState.setQuery(LibraryTab.Albums, query) } + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + fun refresh() { viewModelScope.launch { repository.invalidateCaches() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index 2f64b93c..06b0ef94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -1,158 +1,76 @@ -/* - * 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 . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.artist - -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import dev.krtirtho.spotube.modules.library.LibraryMediaCollection +import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* @Composable fun LibraryArtistsScreen( modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryArtistsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() + when (val current = state) { + is LibraryArtistsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() + else -> { + val data = current as? LibraryArtistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryArtistsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_artists_no_results, + data.query + ) + else stringResource(Res.string.library_artists_empty) + ) } - } - } - - Column(modifier = modifier) { - when (state) { - is LibraryArtistsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } - - is LibraryArtistsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryArtistsState.Error).message, - onRetry = { viewModel.refresh() }) - } - } - - is LibraryArtistsState.Data -> { - val dataState = state as LibraryArtistsState.Data - if (dataState.items.isEmpty() && state !is LibraryArtistsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotBlank() -> Text("No artists found for '${dataState.query}'") - else -> Text("No saved artists") - } - } - } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(dataState.items, key = { it.id }) { artist -> - ArtistCard( - artist = artist, - modifier = Modifier.fillMaxWidth() - ) - } - -// if (state is LibraryArtistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/400x400" - ) - } - } -// } - } - } - } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryArtistsState.Loading, + loadingMore = current is LibraryArtistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + ArtistCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + AvatarCard( + stringResource(Res.string.media_preview_subtitle), + stringResource(Res.string.media_artist), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt index fa5556ce..7e8cb1df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -51,14 +51,13 @@ sealed interface LibraryArtistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { artist -> - artist.name.contains(query, ignoreCase = true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, @@ -72,14 +71,13 @@ sealed interface LibraryArtistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { artist -> - artist.name.contains(query, ignoreCase = true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) } + } else { + allItems + } } } @@ -164,6 +162,12 @@ class LibraryArtistsViewModel( libraryState.setQuery(LibraryTab.Artists, query) } + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + fun refresh() { viewModelScope.launch { repository.invalidateCaches() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 71e3c4f6..1cd80768 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -1,31 +1,8 @@ -/* - * 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 . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.local_tracks - -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress -import org.spotube.millennium.composables.MillenniumListTile -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,161 +10,288 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items - -import com.composeunstyled.Text - +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.getPlatform +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork +import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState +import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 -import dev.krtirtho.spotube.resources.iconsax.Play +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.pluralStringResource import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* @Composable -fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) { - val viewModel = koinViewModel() +fun LibraryLocalTracksScreen( + modifier: Modifier = Modifier, + viewModel: LibraryLocalTracksViewModel = koinViewModel() +) { val state by viewModel.state.collectAsStateWithLifecycle() - val permissionState = rememberLocalMediaPermissionState() - val platformType = remember { getPlatform().type } - val isAndroid = platformType == PlatformType.Android + val permission = rememberLocalMediaPermissionState() + // Platform permission events only; the ViewModel decides whether/when to index media. + LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } + LocalTracksContent( + state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, + viewModel::playCurrentFolder, permission.requestPermission, modifier + ) +} - LaunchedEffect(permissionState.isGranted, isAndroid) { - if (!isAndroid || permissionState.isGranted) { - viewModel.refresh() - } - } - - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - - Box(modifier = modifier.fillMaxWidth()) { +@Composable +internal fun LocalTracksContent( + state: LibraryLocalTracksState, + onOpenFolder: (String) -> Unit, + onPlayTrack: (String) -> Unit, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + onRequestPermission: () -> Unit, + modifier: Modifier = Modifier +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - verticalArrangement = Arrangement.spacedBy(12.dp) + Modifier.widthIn(max = 960.dp).fillMaxSize() ) { - if (state.currentFolderPath != null) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - MillenniumIconButton( - icon = Iconsax.ArrowLeft3, - contentDescription = "Back", - onClick = viewModel::navigateUp, - style = MillenniumIconButtonStyle.Quiet - ) - Text(viewModel.currentFolder()?.name ?: "Folder") - } - MillenniumIconButton( - icon = Iconsax.IconsaxPlayCircle2, - contentDescription = "Play folder", - onClick = { - viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } - }, - style = MillenniumIconButtonStyle.Quiet - ) - } - } - - val folders = viewModel.visibleFolders() - val tracks = viewModel.visibleTracks() - - if (isAndroid && !permissionState.isGranted) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text("Allow audio permission to show local tracks") - MillenniumButton( - onClick = permissionState.requestPermission, - style = MillenniumButtonStyle.Quiet - ) { - Text("Grant access") + LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder) + when { + !state.canAccessMedia -> MediaEmptyState( + Iconsax.IconsaxFolderOpen, + stringResource(Res.string.library_local_permission_title), + Modifier.padding(bottom = inset), + stringResource(Res.string.library_local_permission_description), + action = { + MillenniumButton(onClick = onRequestPermission) { + Text( + stringResource( + Res.string.library_local_grant_access + ) + ) } - } - } - } else if (state.isRefreshing && folders.isEmpty() && tracks.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + }) + + state.isRefreshing && state.visibleFolders.isEmpty() && state.visibleTracks.isEmpty() -> Box( + Modifier.fillMaxSize().padding(bottom = inset), + contentAlignment = Alignment.Center + ) { MillenniumIndeterminateProgress() } - } else if (state.currentFolderPath == null) { - if (folders.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(state.error ?: "No local folders found") - } - } else { - LazyColumn(contentPadding = contentPadding) { - items(folders, key = { it.path }) { folder -> - MillenniumListTile( - title = { Text(folder.name) }, - subtitle = { Text("${folder.trackCount} track(s)") }, - leading = { - MillenniumIcon( - imageVector = Iconsax.IconsaxFolderOpen, - contentDescription = null - ) - }, - onClick = { viewModel.openFolder(folder.path) }) - } + + state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.visibleFolders, key = { it.path }) { folder -> + LocalFolderRow(folder) { onOpenFolder(folder.path) } } } - } else { - if (tracks.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No tracks in this folder") - } - } else { - LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) { - items(tracks, key = { it.path }) { track -> - MillenniumListTile( - title = { Text(track.name) }, - subtitle = { - val artists = - track.artists.joinToString().ifBlank { "Unknown artist" } - val album = track.album?.takeIf { it.isNotBlank() } - Text(if (album == null) artists else "$artists - $album") - }, - leading = { - MillenniumIcon( - imageVector = Iconsax.IconsaxMusicCircle, - contentDescription = null + + state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.visibleTracks, key = { it.path }) { track -> + PlayableListTile( + track.title, + track.subtitle ?: stringResource(Res.string.media_unknown_artist), + imageModel = track.artwork, + onClick = { onPlayTrack(track.path) }, + onPlay = { onPlayTrack(track.path) }, + trailingContent = { + track.durationLabel?.let { + Text( + it, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) - }, - trailing = { - MillenniumIconButton( - icon = Iconsax.Play, - contentDescription = "Play", - onClick = { viewModel.playTrack(track.path) }, - style = MillenniumIconButtonStyle.Quiet - ) - }, - onClick = { viewModel.playTrack(track.path) }) - } + } + }) } } + + else -> MediaEmptyState( + Iconsax.IconsaxFolderOpen, + state.error ?: stringResource( + when { + state.query.isNotBlank() -> Res.string.library_local_no_results + state.currentFolderPath == null -> Res.string.library_local_empty_folders + else -> Res.string.library_local_empty_tracks + } + ), Modifier.padding(bottom = inset) + ) } } } } + +@Composable +internal fun LocalTracksBreadcrumb( + state: LibraryLocalTracksState, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + modifier: Modifier = Modifier +) { + val backDescription = stringResource(Res.string.library_local_back) + Row( + modifier.fillMaxWidth().height(32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(14.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + if (state.currentFolderPath != null) { + MillenniumButton( + onClick = onNavigateUp, + style = MillenniumButtonStyle.Quiet, + modifier = Modifier.height(28.dp) + .semantics { contentDescription = backDescription }, + padding = PaddingValues(0.dp), + minSize = DpSize(0.dp, 28.dp), + foreground = AppTheme.colors.primary + ) { + Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall + ) + } + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(12.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + state.currentFolder?.name ?: stringResource(Res.string.library_local_folder), + style = AppTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } else Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Text( + if (state.currentFolderPath != null) pluralStringResource( + Res.plurals.library_local_tracks_count, + state.visibleTracks.size, + state.visibleTracks.size + ) + else stringResource( + Res.string.library_local_summary, + pluralStringResource( + Res.plurals.library_local_folders_count, + state.totalFolderCount, + state.totalFolderCount + ), + pluralStringResource( + Res.plurals.library_local_tracks_count, + state.totalTrackCount, + state.totalTrackCount + ) + ), + style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant, + maxLines = 1 + ) + if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp)) + if (state.currentFolderPath != null) MillenniumIconButton( + onClick = onPlayFolder, enabled = state.canPlayFolder, + modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxPlay, + stringResource(Res.string.library_local_play_folder), + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { + MillenniumListTile( + onClick = onClick, + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + contentPadding = PaddingValues(12.dp), + minimumHeight = 76.dp, + leading = { + MediaArtwork( + folder.artwork, + folder.name, + Modifier.size(52.dp), + placeholderIcon = Iconsax.IconsaxFolderOpen + ) + }, + title = { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = { + Text( + pluralStringResource( + Res.plurals.library_local_tracks_count, + folder.trackCount, + folder.trackCount + ), style = AppTheme.typography.bodySmall + ) + }, + trailing = { + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(18.dp) + ) + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index 63a0d18d..84b14cff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -33,25 +33,103 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import dev.krtirtho.spotube.PlatformType +import dev.krtirtho.spotube.getPlatform +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_local_refresh_failed + +data class LocalFolderUi( + val path: String, + val name: String, + val trackCount: Int, + val artwork: ByteArray? +) + +data class LocalTrackUi( + val path: String, + val title: String, + val subtitle: String?, + val durationLabel: String?, + val artwork: ByteArray? +) data class LibraryLocalTracksState( val isRefreshing: Boolean = false, val folders: List = emptyList(), val currentFolderPath: String? = null, val query: String = "", - val error: String? = null + val error: String? = null, + val canAccessMedia: Boolean = true, + val visibleFolders: List = emptyList(), + val visibleTracks: List = emptyList(), + val currentFolder: LocalMediaFolder? = null, + val totalFolderCount: Int = 0, + val totalTrackCount: Int = 0, + val canPlayFolder: Boolean = false ) +internal fun localTracksUiState( + source: LibraryLocalTracksState, + query: String +): LibraryLocalTracksState { + val normalized = query.trim() + val folder = source.folders.firstOrNull { it.path == source.currentFolderPath } + val folders = source.folders.filter { + normalized.isEmpty() || it.name.contains( + normalized, + true + ) || it.tracks.any { track -> localTrackMatches(track, normalized) } + } + val tracks = folder?.tracks.orEmpty().filter { localTrackMatches(it, normalized) } + return source.copy( + query = query, + currentFolder = folder, + visibleFolders = folders.map { + LocalFolderUi( + it.path, + it.name, + it.trackCount, + it.tracks.firstOrNull { track -> track.coverBytes?.isNotEmpty() == true }?.coverBytes + ) + }, + visibleTracks = tracks.map { track -> + val seconds = track.durationMs / 1000 + LocalTrackUi( + track.path, track.name, + listOfNotNull( + track.artists.joinToString(", ").takeIf { it.isNotBlank() }, + track.album?.takeIf { it.isNotBlank() }).joinToString(" • ") + .takeIf { it.isNotBlank() }, + if (seconds > 0) "${seconds / 60}:${ + (seconds % 60).toString().padStart(2, '0') + }" else null, + track.coverBytes?.takeIf { it.isNotEmpty() }) + }, + totalFolderCount = source.folders.size, + totalTrackCount = source.folders.sumOf { it.trackCount }, + canPlayFolder = source.canAccessMedia && tracks.isNotEmpty() + ) +} + +private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean = query.isBlank() || + track.name.contains(query, true) || track.album?.contains( + query, + true +) == true || track.artists.any { it.contains(query, true) } + class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, private val audioPlayerQueue: AudioPlayerQueue, private val libraryState: LibraryState ) : ViewModel() { - private val _state = MutableStateFlow(LibraryLocalTracksState()) + private val requiresPermission = getPlatform().type == PlatformType.Android + private val _state = + MutableStateFlow(LibraryLocalTracksState(canAccessMedia = !requiresPermission)) val state: StateFlow = combine( _state, libraryState.queryFlow(LibraryTab.LocalTracks) - ) { state, query -> state.copy(query = query) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState()) + ) { state, query -> localTracksUiState(state, query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), _state.value) init { viewModelScope.launch { @@ -85,14 +163,28 @@ class LibraryLocalTracksViewModel( _state.update { it.copy(currentFolderPath = null) } } + fun onPermissionChanged(granted: Boolean) { + val accessible = !requiresPermission || granted + val wasAccessible = _state.value.canAccessMedia + _state.update { it.copy(canAccessMedia = accessible) } + if (accessible && !wasAccessible) refresh() + } + + fun playCurrentFolder() { + _state.value.currentFolderPath?.let(::playFolder) + } + fun refresh() { + if (!_state.value.canAccessMedia) return viewModelScope.launch { _state.update { it.copy(isRefreshing = true, error = null) } runCatching { coordinator.refreshNow("manual") }.onFailure { throwable -> + val message = + throwable.message ?: getString(Res.string.library_local_refresh_failed) _state.update { - it.copy(error = throwable.message ?: "Failed to refresh local media") + it.copy(error = message) } } _state.update { it.copy(isRefreshing = false) } @@ -100,6 +192,7 @@ class LibraryLocalTracksViewModel( } fun playFolder(folderPath: String) { + if (!_state.value.canAccessMedia) return val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return val tracks = filterTracks(folder.tracks, state.value.query) if (tracks.isEmpty()) return @@ -115,6 +208,7 @@ class LibraryLocalTracksViewModel( } fun playTrack(trackPath: String) { + if (!_state.value.canAccessMedia) return val folder = currentFolder() ?: return val tracks = filterTracks(folder.tracks, state.value.query) val selectedIndex = tracks.indexOfFirst { it.path == trackPath } @@ -130,28 +224,7 @@ class LibraryLocalTracksViewModel( } } - fun visibleFolders(): List { - val query = state.value.query.trim() - if (query.isBlank()) return state.value.folders - - return state.value.folders.filter { folder -> - folder.name.contains(query, ignoreCase = true) || - folder.tracks.any { track -> - track.name.contains(query, ignoreCase = true) || - track.artists.any { it.contains(query, ignoreCase = true) } - } - } - } - - fun visibleTracks(): List { - val folder = currentFolder() ?: return emptyList() - return filterTracks(folder.tracks, state.value.query) - } - - fun currentFolder(): LocalMediaFolder? { - val path = state.value.currentFolderPath ?: return null - return state.value.folders.firstOrNull { it.path == path } - } + private fun currentFolder(): LocalMediaFolder? = state.value.currentFolder private fun refreshIfEmpty() { if (_state.value.folders.isNotEmpty()) return @@ -159,6 +232,7 @@ class LibraryLocalTracksViewModel( } private fun refreshIfStale() { + if (!_state.value.canAccessMedia) return viewModelScope.launch { runCatching { coordinator.refreshIfStale() @@ -170,11 +244,7 @@ class LibraryLocalTracksViewModel( val normalizedQuery = query.trim() if (normalizedQuery.isBlank()) return tracks - return tracks.filter { track -> - track.name.contains(normalizedQuery, ignoreCase = true) || - track.album?.contains(normalizedQuery, ignoreCase = true) == true || - track.artists.any { it.contains(normalizedQuery, ignoreCase = true) } - } + return tracks.filter { localTrackMatches(it, normalizedQuery) } } private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index e5d41549..56ad4381 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -1,180 +1,94 @@ -/* - * 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 . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.playlist - -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import dev.krtirtho.spotube.modules.library.LibraryMediaCollection +import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* @Composable fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, showCreatePlaylist: Boolean = false, onDismissCreatePlaylist: () -> Unit = {}, + viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryPlaylistsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) + Column(modifier) { + when (val current = state) { + is LibraryPlaylistsState.Error -> Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() - } - } - } - Column(modifier = modifier) { - when (state) { - is LibraryPlaylistsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - PlayableCard( - title = "Playlist Name", - subtitle = "By Artist", - imageURL = "https://placehold.co/600x400" + else -> { + val data = current as? LibraryPlaylistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_playlists_no_results, + data.query ) - } - } - } - } - - is LibraryPlaylistsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryPlaylistsState.Error).message, - onRetry = { viewModel.refresh() }) - } - } - - is LibraryPlaylistsState.Data -> { - val dataState = state as LibraryPlaylistsState.Data - if (dataState.items.isEmpty() && state !is LibraryPlaylistsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotEmpty() -> Text("No playlists found for '${dataState.query}'") - else -> Text("No saved playlists") - } + else stringResource(Res.string.library_playlists_empty) + ) } } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { + LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryPlaylistsState.Loading, + loadingMore = current is LibraryPlaylistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + header = { list -> LikedTracksCard( - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + listMode = list ) - } - - items(dataState.items, key = { it.id }) { playlist -> + }, + itemContent = { item, list -> PlaylistCard( - playlist = playlist, - modifier = Modifier.fillMaxWidth() + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list ) } - - if (state is LibraryPlaylistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - PlayableCard( - title = "Playlist Name", - subtitle = "By Artist", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } + ) } } } - PlaylistFormSheet( visible = showCreatePlaylist, onDismiss = onDismissCreatePlaylist, - onSubmit = { data -> - viewModel.createPlaylist( - name = data.name, - description = data.description.ifBlank { null }, - isPublic = data.isPublic, - isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64, - trackIds = emptyList() - ) - }) + onSubmit = viewModel::createPlaylist + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 0072a4ac..4c6662aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -51,15 +51,14 @@ sealed interface LibraryPlaylistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { - it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || + (it.description?.contains(query, ignoreCase = true) == true) } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, @@ -73,15 +72,14 @@ sealed interface LibraryPlaylistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { - it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || + (it.description?.contains(query, ignoreCase = true) == true) } + } else { + allItems + } } } @@ -152,6 +150,17 @@ class LibraryPlaylistsViewModel( libraryState.setQuery(LibraryTab.Playlists, query) } + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + + fun createPlaylist(data: PlaylistFormData) = createPlaylist( + data.name, data.description.ifBlank { null }, data.isPublic, + data.isCollaborating, data.imageBase64, emptyList() + ) + fun refresh() { viewModelScope.launch { repository.invalidateCaches() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index 7cc96696..ffcf1dfb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -74,7 +74,8 @@ data class PlaylistFormData( val isPublic: Boolean = true, val isCollaborating: Boolean = false, val imageBase64: String = "", - val imagePreviewUrl: String? = null) + val imagePreviewUrl: String? = null +) @Composable fun PlaylistFormSheet( @@ -83,7 +84,8 @@ fun PlaylistFormSheet( onSubmit: (PlaylistFormData) -> Unit, modifier: Modifier = Modifier, initialData: PlaylistFormData? = null, - isEditing: Boolean = false) { + isEditing: Boolean = false +) { if (!visible) return val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium @@ -91,7 +93,11 @@ fun PlaylistFormSheet( var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") } var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") } var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) } - var isCollaborating by remember(initialData) { mutableStateOf(initialData?.isCollaborating ?: false) } + var isCollaborating by remember(initialData) { + mutableStateOf( + initialData?.isCollaborating ?: false + ) + } var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } @@ -131,7 +137,8 @@ fun PlaylistFormSheet( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl) + imagePreviewUrl = imagePreviewUrl + ) val handleImagePick = { imagePicker.launch() } @@ -140,7 +147,8 @@ fun PlaylistFormSheet( visible = true, onDismissRequest = onDismiss, title = if (isEditing) "Edit Playlist" else "Create Playlist", - modifier = modifier) { + modifier = modifier + ) { PlaylistFormFields( nameState = nameState, descriptionState = descriptionState, @@ -150,19 +158,22 @@ fun PlaylistFormSheet( onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick) + onPickImage = handleImagePick + ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumButton( onClick = { onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank()) { + enabled = name.isNotBlank() + ) { Text(if (isEditing) "Save" else "Create") } MillenniumButton( onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { + style = MillenniumButtonStyle.Secondary + ) { Text("Cancel") } } @@ -173,11 +184,13 @@ fun PlaylistFormSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp)) { + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { Text( text = if (isEditing) "Edit Playlist" else "Create Playlist", style = AppTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp)) + modifier = Modifier.padding(bottom = 16.dp) + ) PlaylistFormFields( nameState = nameState, descriptionState = descriptionState, @@ -187,14 +200,17 @@ fun PlaylistFormSheet( onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick) + onPickImage = handleImagePick + ) Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { MillenniumButton( onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { + style = MillenniumButtonStyle.Secondary + ) { Text("Cancel") } MillenniumButton( @@ -202,7 +218,8 @@ fun PlaylistFormSheet( onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank()) { + enabled = name.isNotBlank() + ) { Text(if (isEditing) "Save" else "Create") } } @@ -222,23 +239,27 @@ private fun PlaylistFormFields( onIsCollaboratingChange: (Boolean) -> Unit, imageBase64: String, imagePreviewUrl: String?, - onPickImage: () -> Unit) { + onPickImage: () -> Unit +) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp)) { + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { MillenniumTextField( state = nameState, label = "Name", modifier = Modifier.fillMaxWidth(), - placeholder = "Playlist name") + placeholder = "Playlist name" + ) MillenniumTextField( state = descriptionState, label = "Description", modifier = Modifier.fillMaxWidth(), - placeholder = "Description (optional)") + placeholder = "Description (optional)" + ) Row( modifier = Modifier @@ -253,7 +274,8 @@ private fun PlaylistFormFields( Text( "Anyone can see this playlist", style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange) } @@ -271,40 +293,49 @@ private fun PlaylistFormFields( Text( "Others can add tracks to this playlist", style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp)) { + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text("Cover Image", style = AppTheme.typography.bodyLarge) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { Box( modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { when { imagePreviewUrl != null -> AsyncImage( model = imagePreviewUrl, contentDescription = "Playlist cover", modifier = Modifier.size(64.dp), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) + imageBase64.isNotBlank() -> { Text( "✓", style = AppTheme.typography.titleMedium, - color = AppTheme.colors.primary) + color = AppTheme.colors.primary + ) } + else -> { Text( "No image", style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt new file mode 100644 index 00000000..8dbc4973 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt @@ -0,0 +1,128 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.* + +data class LocalMediaFolderRowUi(val path: String, val name: String) + +data class LocalMediaFoldersUiState( + val isOpen: Boolean = false, + val configuredCount: Int = 0, + val singleConfiguredPath: String? = null, + val folders: List = emptyList(), + val isSaving: Boolean = false, + val canSave: Boolean = false, + val saveFailed: Boolean = false, + val automaticNote: StringResource? = null +) + +private data class FolderDraft( + val isOpen: Boolean = false, + val original: List = emptyList(), + val paths: List = emptyList(), + val isSaving: Boolean = false, + val saveFailed: Boolean = false +) + +/** The editor keeps a snapshot until Save; incoming settings updates never reset a draft. */ +class LocalMediaFoldersViewModel( + private val settings: StateFlow, + private val persistFolders: suspend (List) -> Unit +) : ViewModel() { + private val draft = MutableStateFlow(FolderDraft()) + + val uiState = combine(settings, draft, ::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, toUiState(settings.value, draft.value)) + + fun open() { + if (draft.value.isOpen || draft.value.isSaving) return + val paths = normalizedFolders(settings.value.localMediaFolders) + draft.value = FolderDraft(isOpen = true, original = paths, paths = paths) + } + + fun addFolder(path: String?) { + val normalized = normalizeMediaFolderPath(path.orEmpty()) + if (normalized.isBlank()) return + draft.update { current -> + if (!current.isOpen || current.isSaving || normalized in current.paths) current + else current.copy(paths = current.paths + normalized, saveFailed = false) + } + } + + fun removeFolder(path: String) { + draft.update { current -> + if (!current.isOpen || current.isSaving) current + else current.copy(paths = current.paths.filterNot { it == path }, saveFailed = false) + } + } + + fun dismiss() { + if (!draft.value.isSaving) draft.value = FolderDraft() + } + + fun save() { + val current = draft.value + if (!current.isOpen || current.isSaving || current.paths == current.original) return + val paths = current.paths.toList() + draft.value = current.copy(isSaving = true, saveFailed = false) + viewModelScope.launch { + try { + persistFolders(paths) + draft.value = FolderDraft() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + draft.update { it.copy(isSaving = false, saveFailed = true) } + } + } + } + + private fun toUiState(settings: UserSettings, draft: FolderDraft): LocalMediaFoldersUiState { + val configured = normalizedFolders(settings.localMediaFolders) + val hasDownloadFolder = !settings.overloadedDownloadFolder.isNullOrBlank() + return LocalMediaFoldersUiState( + isOpen = draft.isOpen, + configuredCount = configured.size, + singleConfiguredPath = configured.singleOrNull(), + folders = draft.paths.map { path -> + LocalMediaFolderRowUi( + path, + path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path }) + }, + isSaving = draft.isSaving, + canSave = draft.isOpen && !draft.isSaving && draft.paths != draft.original, + saveFailed = draft.saveFailed, + automaticNote = when { + hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both + hasDownloadFolder -> Res.string.settings_local_media_folders_automatic_downloads + settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_cache + else -> null + } + ) + } +} + +private fun normalizedFolders(paths: List) = + paths.map(::normalizeMediaFolderPath).filter { it.isNotBlank() }.distinct() + +internal fun normalizeMediaFolderPath(path: String): String { + val trimmed = path.trim() + if (trimmed == "/" || trimmed == "\\") return trimmed + if (trimmed.length >= 3 && trimmed[0].isLetter() && trimmed[1] == ':' && + trimmed.substring(2).all { it == '/' || it == '\\' } + ) return trimmed.take(3) + return trimmed.trimEnd('/', '\\') +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt new file mode 100644 index 00000000..581a2dcf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -0,0 +1,257 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.settings.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import dev.krtirtho.spotube.modules.settings.LocalMediaFolderRowUi +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive +import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen +import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +internal fun LocalMediaFoldersEditorContent( + state: LocalMediaFoldersUiState, + onAddFolder: () -> Unit, + onRemoveFolder: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(Res.string.settings_local_media_folders_description), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + Row( + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + pluralStringResource( + Res.plurals.settings_local_media_folders_count, + state.folders.size, + state.folders.size + ), + style = AppTheme.typography.labelMedium, modifier = Modifier.weight(1f) + ) + MillenniumButton( + onClick = onAddFolder, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary, + padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + minSize = DpSize(0.dp, 36.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon(Iconsax.IconsaxAddSquare, null, modifier = Modifier.size(16.dp)) + Text( + stringResource(Res.string.settings_local_media_folders_add_action), + style = AppTheme.typography.labelMedium + ) + } + } + } + Box( + Modifier.fillMaxWidth() + .heightIn(min = if (state.folders.isEmpty()) 104.dp else 76.dp, max = 240.dp) + .millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + if (state.folders.isEmpty()) { + Column( + Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(Res.string.settings_local_media_folders_none_added), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } else { + LazyColumn( + Modifier.fillMaxWidth(), + state = listState, + contentPadding = PaddingValues(vertical = 4.dp) + ) { + itemsIndexed( + state.folders, + key = { _, folder -> folder.path }) { index, folder -> + if (index > 0) MillenniumSeparator(Modifier.padding(horizontal = 12.dp)) + LocalMediaFolderEditorRow( + folder, + !state.isSaving + ) { onRemoveFolder(folder.path) } + } + } + if (listState.canScrollForward || listState.canScrollBackward) { + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } + state.automaticNote?.let { note -> + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(note), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + if (state.saveFailed) Text( + stringResource(Res.string.settings_local_media_folders_save_error), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.error + ) + MillenniumSeparator() + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MillenniumButton( + onClick = onCancel, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.settings_action_cancel)) + } + MillenniumButton(onClick = onSave, enabled = state.canSave) { + Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.settings_action_save)) + } + } + } +} + +@Composable +private fun LocalMediaFolderEditorRow( + folder: LocalMediaFolderRowUi, + enabled: Boolean, + onRemove: () -> Unit +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(36.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(6.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary + ) + } + Box(Modifier.weight(1f)) { + MillenniumTooltip(folder.path) { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + folder.path, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp + ), + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + val removeLabel = + stringResource(Res.string.settings_local_media_folders_remove_named, folder.name) + MillenniumTooltip(removeLabel) { + MillenniumIconButton( + onClick = onRemove, + enabled = enabled, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon(Iconsax.IconsaxTrash, removeLabel, modifier = Modifier.size(16.dp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index 62350c83..a4656eea 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -18,61 +18,42 @@ package dev.krtirtho.spotube.modules.settings.sections -import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState +import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.path import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.settings_action_cancel -import spotube.composeapp.generated.resources.settings_action_save import spotube.composeapp.generated.resources.settings_download_folder_current import spotube.composeapp.generated.resources.settings_download_folder_default import spotube.composeapp.generated.resources.settings_download_folder_title import spotube.composeapp.generated.resources.settings_download_format_subtitle_current import spotube.composeapp.generated.resources.settings_download_format_title import spotube.composeapp.generated.resources.settings_download_quality_title -import spotube.composeapp.generated.resources.settings_local_media_folders_add_action -import spotube.composeapp.generated.resources.settings_local_media_folders_description import spotube.composeapp.generated.resources.settings_local_media_folders_manage -import spotube.composeapp.generated.resources.settings_local_media_folders_none_added -import spotube.composeapp.generated.resources.settings_local_media_folders_remove_action import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_current import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty import spotube.composeapp.generated.resources.settings_local_media_folders_title @@ -82,11 +63,13 @@ import spotube.composeapp.generated.resources.settings_subtitle_current internal fun LazyListScope.downloadsSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true) { + includeHeader: Boolean = true +) { val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) val downloadQualities = availableAudioQualities( format = settings.downloadMusicFormat, - current = settings.downloadMusicQuality) + current = settings.downloadMusicQuality + ) if (includeHeader) { settingsSectionHeader(Res.string.settings_section_downloads) @@ -104,14 +87,7 @@ internal fun LazyListScope.downloadsSection( ) }, { - LocalMediaFoldersSettingCard( - folders = settings.localMediaFolders, - onFoldersSaved = { folders -> - settingsViewModel.updateSettings { - copy(localMediaFolders = folders) - } - } - ) + LocalMediaFoldersSettingCard() }, { SelectionSettingCard( @@ -121,7 +97,10 @@ internal fun LazyListScope.downloadsSection( settings.downloadMusicFormat.displayLabel() ), icon = { - SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_download_format_title)) + SettingsItemIcon( + Iconsax.IconsaxMusicPlay, + stringResource(Res.string.settings_download_format_title) + ) }, selectedOption = settings.downloadMusicFormat, options = downloadFormats, @@ -130,7 +109,8 @@ internal fun LazyListScope.downloadsSection( settingsViewModel.updateSettings { copy( downloadMusicFormat = format, - downloadMusicQuality = format.resolveQuality(downloadMusicQuality)) + downloadMusicQuality = format.resolveQuality(downloadMusicQuality) + ) } } ) @@ -143,7 +123,10 @@ internal fun LazyListScope.downloadsSection( settings.downloadMusicQuality.displayLabel() ), icon = { - SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_download_quality_title)) + SettingsItemIcon( + Iconsax.IconsaxVerticalSetting, + stringResource(Res.string.settings_download_quality_title) + ) }, selectedOption = settings.downloadMusicQuality, options = downloadQualities, @@ -161,7 +144,8 @@ internal fun LazyListScope.downloadsSection( @Composable private fun DownloadFolderSettingCard( folder: String?, - onFolderSelected: (String?) -> Unit) { + onFolderSelected: (String?) -> Unit +) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> if (directory != null) { val normalized = normalizePath(directory.path) @@ -177,14 +161,18 @@ private fun DownloadFolderSettingCard( stringResource(Res.string.settings_download_folder_current, it) } ?: stringResource(Res.string.settings_download_folder_default), icon = { - SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title)) + SettingsItemIcon( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.settings_download_folder_title) + ) }, trailingContent = { MillenniumIconButton( icon = Iconsax.IconsaxFolderAdd, contentDescription = stringResource(Res.string.settings_download_folder_title), onClick = { pickerLauncher.launch() }, - style = MillenniumIconButtonStyle.Quiet) + style = MillenniumIconButtonStyle.Quiet + ) }, onClick = { pickerLauncher.launch() @@ -194,17 +182,17 @@ private fun DownloadFolderSettingCard( @Composable private fun LocalMediaFoldersSettingCard( - folders: List, - onFoldersSaved: (List) -> Unit) { - var isDialogOpen by remember { mutableStateOf(false) } - - val subtitlePreview = when { - folders.isEmpty() -> stringResource(Res.string.settings_local_media_folders_subtitle_empty) - folders.size == 1 -> folders.first() - else -> stringResource( - Res.string.settings_local_media_folders_subtitle_current, - folders.size) - } + viewModel: LocalMediaFoldersViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val pickerLauncher = + rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) } + val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) { + stringResource(Res.string.settings_local_media_folders_subtitle_empty) + } else stringResource( + Res.string.settings_local_media_folders_subtitle_current, + state.configuredCount + ) SettingCardItem( title = stringResource(Res.string.settings_local_media_folders_title), @@ -212,117 +200,44 @@ private fun LocalMediaFoldersSettingCard( icon = { SettingsItemIcon( Iconsax.IconsaxFolder, - stringResource(Res.string.settings_local_media_folders_title)) + stringResource(Res.string.settings_local_media_folders_title) + ) }, trailingContent = { MillenniumButton( - onClick = { isDialogOpen = true }, - style = MillenniumButtonStyle.Secondary) { + onClick = viewModel::open, + style = MillenniumButtonStyle.Secondary + ) { Text(stringResource(Res.string.settings_local_media_folders_manage)) } }, - onClick = { - isDialogOpen = true - } + onClick = viewModel::open ) - if (isDialogOpen) { + if (state.isOpen) { LocalMediaFoldersDialog( - folders = folders, - onDismiss = { isDialogOpen = false }, - onFoldersSaved = onFoldersSaved) + state = state, + onAddFolder = { pickerLauncher.launch() }, + onRemoveFolder = viewModel::removeFolder, + onSave = viewModel::save, + onDismiss = viewModel::dismiss + ) } } @Composable private fun LocalMediaFoldersDialog( - folders: List, - onDismiss: () -> Unit, - onFoldersSaved: (List) -> Unit) { - val draftFolders = remember(folders) { - mutableStateListOf().apply { addAll(folders) } - } - val pickerLauncher = rememberDirectoryPickerLauncher { directory -> - if (directory != null) { - val normalized = normalizePath(directory.path) - if (normalized.isNotEmpty() && draftFolders.none { normalizePath(it) == normalized }) { - draftFolders.add(normalized) - } - } - } - + state: LocalMediaFoldersUiState, + onAddFolder: () -> Unit, + onRemoveFolder: (String) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit +) { MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = stringResource(Res.string.settings_local_media_folders_title)) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(Res.string.settings_local_media_folders_description), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) - - MillenniumButton( - onClick = { pickerLauncher.launch() }, - style = MillenniumButtonStyle.Secondary) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically) { - MillenniumIcon(Iconsax.IconsaxAddSquare, contentDescription = null) - Text(stringResource(Res.string.settings_local_media_folders_add_action)) - } - } - - if (draftFolders.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Text( - text = stringResource(Res.string.settings_local_media_folders_none_added), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) - } - } else { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - draftFolders.forEachIndexed { index, folder -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically) { - Text( - text = folder, - modifier = Modifier.weight(1f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = AppTheme.typography.bodySmall) - MillenniumIconButton( - onClick = { - if (index in draftFolders.indices) { - draftFolders.removeAt(index) - } - }, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = stringResource( - Res.string.settings_local_media_folders_remove_action)) - } - } - } - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton( - onClick = { - onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) - onDismiss() - }) { - Text(stringResource(Res.string.settings_action_save)) - } - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { - Text(stringResource(Res.string.settings_action_cancel)) - } - } - } + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent(state, onAddFolder, onRemoveFolder, onSave, onDismiss) } } - diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt index e5ce8585..872ae802 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt @@ -38,6 +38,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) class LibraryToolbarTest { @@ -109,6 +110,29 @@ class LibraryToolbarTest { assertEquals("Radiohead", library.uiState.value.searchState.text.toString()) } + @Test + fun viewModeDefaultsAndOverridesArePerTab() = runTest { + assertEquals(LibraryViewMode.List, defaultLibraryViewMode(true)) + assertEquals(LibraryViewMode.Grid, defaultLibraryViewMode(false)) + library.toggleViewMode(isCompact = true) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Albums) + runCurrent() + assertNull(library.uiState.value.viewModeOverride) + library.toggleViewMode(isCompact = false) + runCurrent() + assertEquals(LibraryViewMode.List, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Playlists) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.LocalTracks) + library.toggleViewMode(isCompact = true) + runCurrent() + assertFalse(library.uiState.value.canChangeView) + assertNull(library.uiState.value.viewModeOverride) + } + @OptIn(ExperimentalComposeUiApi::class) @Test fun renderExpandedCompactAndSearchToolbars() { @@ -128,14 +152,29 @@ class LibraryToolbarTest { searchMode = searching, searchState = TextFieldState(if (searching) "Muse" else ""), canCreatePlaylist = tab == LibraryTab.Playlists, - canRefresh = tab == LibraryTab.LocalTracks + canRefresh = tab == LibraryTab.LocalTracks, + canChangeView = tab != LibraryTab.LocalTracks ) ImageComposeScene(width, 88) { SpotubeTheme(UserSettings(theme = Theme.DARK)) { MillenniumTopAppBar( - title = { LibraryToolbarTitle(state, expandedDesktop) }, + title = { + LibraryToolbarTitle( + state, + expandedDesktop, + mode.startsWith("compact-desktop") + ) + }, actions = { - LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {}) + LibraryToolbarActions( + state, + expandedDesktop, + {}, + {}, + {}, + {}, + compactDesktop = mode.startsWith("compact-desktop") + ) if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer( Modifier.width(132.dp) ) diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt new file mode 100644 index 00000000..dec49ce7 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt @@ -0,0 +1,415 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard +import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile +import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme +import dev.krtirtho.spotube.modules.downloads.DownloadItem +import dev.krtirtho.spotube.modules.downloads.DownloadRowAction +import dev.krtirtho.spotube.modules.downloads.DownloadStatus +import dev.krtirtho.spotube.modules.downloads.DownloadsContent +import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarTitle +import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarSummary +import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarActions +import dev.krtirtho.spotube.modules.downloads.downloadsUiState +import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksState +import dev.krtirtho.spotube.modules.library.local_tracks.LocalTracksContent +import dev.krtirtho.spotube.modules.library.local_tracks.LocalTracksBreadcrumb +import dev.krtirtho.spotube.modules.library.local_tracks.localTracksUiState +import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder +import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack +import dev.krtirtho.spotube.modules.settings.Theme +import dev.krtirtho.spotube.modules.settings.UserSettings +import java.io.File +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.composables.MillenniumTopAppBar +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.media_artist +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalComposeUiApi::class) +class MediaLibraryUiTest { + private val tracks = listOf( + LocalMediaTrack("/music/one.mp3", "Nishongota", listOf("Aurora"), 185_000, "Clancy"), + LocalMediaTrack( + "/music/two.mp3", + "Amay Prashna Kare Neel Dhrubatara", + listOf("Hemanta Mukherjee"), + 195_000 + ), + LocalMediaTrack("/downloads/three.mp3", "Whatever It Takes", emptyList(), 0) + ) + private val folders = listOf( + LocalMediaFolder("/music", "Music", tracks.take(2)), + LocalMediaFolder("/downloads", "Downloads", tracks.takeLast(1)) + ) + private val artists = listOf("Aurora", "Hemanta Mukherjee", "Imagine Dragons") + private val downloadItems = listOf( + DownloadItem("1", tracks[0].name, "Aurora", null, DownloadStatus.Downloading, 0.48f), + DownloadItem("2", tracks[1].name, "Hemanta Mukherjee", null, DownloadStatus.Completed), + DownloadItem( + "3", + tracks[2].name, + "Imagine Dragons", + null, + DownloadStatus.Failed("Connection lost") + ), + DownloadItem("4", "Clancy", "Twenty One Pilots", null, DownloadStatus.Queued) + ) + + @Test + fun downloadRowsHaveReadyProgressAndCorrectActions() { + val state = downloadsUiState(downloadItems) + assertEquals(2, state.activeCount) + assertEquals(1, state.completedCount) + assertTrue(state.canClearCompleted) + assertEquals(48, state.items[0].percent) + assertEquals(DownloadRowAction.Cancel, state.items[0].action) + assertEquals(DownloadRowAction.Remove, state.items[1].action) + assertEquals(DownloadRowAction.Retry, state.items[2].action) + assertEquals("Connection lost", state.items[2].error) + assertEquals( + 0f, + downloadsUiState(listOf(downloadItems[0].copy(progress = Float.NaN))).items.single().progress + ) + } + + @Test + fun localMediaFilteringAndMetadataAreComputedBeforeRendering() { + val base = LibraryLocalTracksState(folders = folders) + val root = localTracksUiState(base, "aurora") + assertEquals(listOf("Music"), root.visibleFolders.map { it.name }) + assertEquals(3, root.totalTrackCount) + val inFolder = localTracksUiState(base.copy(currentFolderPath = "/music"), "clancy") + assertEquals(listOf(tracks[0].name), inFolder.visibleTracks.map { it.title }) + assertEquals("3:05", inFolder.visibleTracks.single().durationLabel) + assertEquals("Aurora • Clancy", inFolder.visibleTracks.single().subtitle) + assertTrue(inFolder.canPlayFolder) + assertFalse( + localTracksUiState( + base.copy( + currentFolderPath = "/music", + canAccessMedia = false + ), "" + ).canPlayFolder + ) + } + + @Test + fun expandedDownloadsHasOneHeaderAndOneClearAction() { + var cleared = 0 + val state = downloadsUiState(downloadItems) + ImageComposeScene(960, 660) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state) { cleared++ } } + ) + DownloadsContent(state, {}, {}, {}, {}, Modifier.weight(1f), showHeader = false) + } + } + }.use { scene -> + repeat(6) { scene.render().close() } + val nodes = allNodes(scene) + val texts = nodes.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() } + .map { it.text } + assertEquals(1, texts.count { it == "Downloads" }) + assertEquals(1, texts.count { it == "Clear completed" }) + val action = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Clear completed" } == true + } + action.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, cleared) + } + } + + @Test + fun breadcrumbIsThinAndItsParentLinkNavigatesUp() { + var height = 0 + var navigatedUp = 0 + val state = localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ) + ImageComposeScene(360, 40) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + LocalTracksBreadcrumb( + state, + { navigatedUp++ }, + {}, + Modifier.onGloballyPositioned { height = it.size.height }) + } + }.use { scene -> + repeat(6) { scene.render().close() } + assertEquals(32, height) + val parent = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Back to folders") == true + } + parent.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, navigatedUp) + } + } + + @Test + fun renderScreensAndCollectionModesInLightAndDark() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 960)) { + render(output, "downloads-$theme-$width", width, theme) { + val state = downloadsUiState(downloadItems) + if (width >= 840) Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, {}) }) + DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.weight(1f).padding(horizontal = 12.dp), + showHeader = false + ) + } else DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "folders-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState(folders = folders), + "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "tracks-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "collection-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = tracks, + itemKey = { it.path }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { item, list -> + PlayableCard( + item.name, + item.artists.joinToString(), + onClick = {}, + onPlay = {}, + onAddToQueue = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "artists-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = artists, + itemKey = { it }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { artist, list -> + AvatarCard( + artist, + stringResource(Res.string.media_artist), + onClick = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + } + } + } + + @Test + fun bothCardTypesRespondToHoverAndClick() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (avatar in listOf(false, true)) { + var clicks = 0 + ImageComposeScene(192, 260) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + .padding(16.dp) + ) { + if (avatar) AvatarCard( + tracks[0].artists.first(), + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + else PlayableCard( + tracks[0].name, + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + } + } + }.use { scene -> + repeat(4) { scene.render().close() } + val before = bytes(scene, 0) + scene.sendPointerEvent(PointerEventType.Enter, Offset(80f, 80f), timeMillis = 0) + scene.sendPointerEvent(PointerEventType.Move, Offset(80f, 80f), timeMillis = 1) + repeat(12) { scene.render((it + 1) * 16_000_000L).close() } + val hovered = bytes(scene, 250_000_000) + assertFalse( + before.contentEquals(hovered), + "Hover should visibly change the ${if (avatar) "avatar" else "playable"} card" + ) + File(output, "card-${if (avatar) "avatar" else "playable"}-hover.png").writeBytes( + hovered + ) + scene.sendPointerEvent( + PointerEventType.Press, + Offset(80f, 80f), + timeMillis = 260, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.render(300_000_000).close() + scene.sendPointerEvent( + PointerEventType.Release, Offset(80f, 80f), timeMillis = 310, + buttons = PointerButtons(), button = PointerButton.Primary + ) + scene.render(350_000_000).close() + assertEquals(1, clicks) + } + } + } + + @Test + fun listPlaybackActionsDoNotTriggerNavigation() { + var navigations = 0 + var plays = 0 + ImageComposeScene(360, 100) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + PlayableListTile(tracks[0].name, onClick = { navigations++ }, onPlay = { plays++ }) + } + }.use { scene -> + repeat(4) { scene.render().close() } + val play = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Play") == true + } + val center = play.boundsInRoot.center + scene.sendPointerEvent( + PointerEventType.Press, + center, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.sendPointerEvent( + PointerEventType.Release, + center, + buttons = PointerButtons(), + button = PointerButton.Primary + ) + scene.render().close() + assertEquals(1, plays) + assertEquals(0, navigations) + } + } + + private fun render( + output: File, + name: String, + width: Int, + theme: Theme, + content: @Composable () -> Unit + ) { + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { content() } + } + }.use { scene -> + repeat(6) { scene.render().close() } + if (name.startsWith("downloads")) { + val texts = allNodes(scene).flatMap { + it.config.getOrNull(SemanticsProperties.Text).orEmpty() + }.map { it.text } + assertTrue("Downloading 48%" in texts) + } + File(output, "$name.png").writeBytes(bytes(scene, 0)) + } + } + + private fun bytes(scene: ImageComposeScene, time: Long): ByteArray = + scene.render(time).use { image -> + assertNotNull(image.encodeToData()).use { it.bytes } + } + + private fun allNodes(scene: ImageComposeScene): List { + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + } +} diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt new file mode 100644 index 00000000..d577fd09 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt @@ -0,0 +1,217 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.use +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme +import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_local_media_folders_title +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LocalMediaFoldersEditorTest { + private val models = mutableListOf() + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun cleanup() { + models.forEach { it.viewModelScope.cancel() } + models.clear() + Dispatchers.resetMain() + } + + private fun model( + source: MutableStateFlow, + persist: suspend (List) -> Unit = {} + ) = + LocalMediaFoldersViewModel(source, persist).also { models.add(it) } + + @Test + fun addingFoldersDeduplicatesPathsAndCancelDiscardsTheDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/user/Music"))) + var writes = 0 + val editor = model(source) { writes++ } + editor.open() + editor.addFolder(" /home/user/Music/// ") + editor.addFolder(" ") + assertFalse(editor.uiState.value.canSave) + editor.addFolder("/home/user/Documents/Music Production/") + runCurrent() + assertEquals( + listOf("Music", "Music Production"), + editor.uiState.value.folders.map { it.name }) + assertTrue(editor.uiState.value.canSave) + editor.dismiss() + editor.addFolder("/late-picker-result") + editor.open() + runCurrent() + assertEquals(listOf("/home/user/Music"), editor.uiState.value.folders.map { it.path }) + assertEquals(0, writes) + } + + @Test + fun settingsUpdatesDoNotResetAnOpenDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val editor = model(source) + editor.open() + editor.addFolder("/new-music") + source.value = source.value.copy(overloadedDownloadFolder = "/downloads") + runCurrent() + assertEquals(listOf("/music", "/new-music"), editor.uiState.value.folders.map { it.path }) + assertTrue(editor.uiState.value.canSave) + } + + @Test + fun saveLocksTheDraftUntilPersistenceCompletes() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val gate = CompletableDeferred() + var writes = 0 + val editor = model(source) { paths -> + writes++ + gate.await() + source.value = source.value.copy(localMediaFolders = paths) + } + editor.open() + editor.addFolder("/new-music") + editor.save() + editor.save() + editor.addFolder("/not-allowed-during-save") + editor.dismiss() + runCurrent() + assertTrue(editor.uiState.value.isSaving) + assertTrue(editor.uiState.value.isOpen) + assertFalse(editor.uiState.value.canSave) + assertEquals(2, editor.uiState.value.folders.size) + gate.complete(Unit) + runCurrent() + assertFalse(editor.uiState.value.isOpen) + assertEquals(listOf("/music", "/new-music"), source.value.localMediaFolders) + assertEquals(1, writes) + } + + @Test + fun failedSaveKeepsChangesForRetry() = runTest { + val source = MutableStateFlow(UserSettings()) + val editor = model(source) { throw IllegalStateException("Test storage failure") } + editor.open() + editor.addFolder("/music") + editor.save() + runCurrent() + assertTrue(editor.uiState.value.isOpen) + assertTrue(editor.uiState.value.saveFailed) + assertTrue(editor.uiState.value.canSave) + assertEquals(listOf("/music"), editor.uiState.value.folders.map { it.path }) + } + + @Test + fun rootPathsStayValid() { + assertEquals("/", normalizeMediaFolderPath(" / ")) + assertEquals("C:\\", normalizeMediaFolderPath("C:\\")) + assertEquals("C:/", normalizeMediaFolderPath("C:///")) + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderDialogAtCompactAndWideSizes() { + val output = File("build/reports/local-media-folders").apply { mkdirs() } + val source = + MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/krtirtho/Documents/Music Production"))) + val editor = model(source) + editor.open() + editor.addFolder("/home/krtirtho/Music") + val oneFolder = editor.uiState.value.copy(folders = editor.uiState.value.folders.take(1)) + val cases = mapOf( + "empty" to oneFolder.copy(folders = emptyList()), + "one" to oneFolder, + "many" to oneFolder.copy(folders = (1..8).map { + LocalMediaFolderRowUi( + "/home/user/Music/Collection-$it", + "Collection-$it" + ) + }) + ) + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 640)) { + for ((name, state) in cases) { + var added = 0 + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { + MillenniumDialog( + visible = true, + onDismissRequest = {}, + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent(state, { added++ }, {}, {}, {}) + } + } + } + }.use { scene -> + repeat(24) { scene.render(it * 16_000_000L).close() } + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + + val nodes = scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + val add = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Add folder" } == true + } + add.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, added) + assertTrue( + add.boundsInRoot.right <= width, + "Actions must fit inside compact dialogs" + ) + scene.render(400_000_000).use { image -> + image.encodeToData()!!.use { + File( + output, + "$name-$theme-$width.png" + ).writeBytes(it.bytes) + } + } + } + } + } + } + } +} From fb96984d6e919dc3d975d91540918721e60863ec Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Oct 2026 13:12:39 +0600 Subject: [PATCH 049/132] feat(ui): integrate LocalTextStyle for dynamic tinting in player controls and UI components --- .mcp.json | 17 + .opencode/opencode.jsonc | 16 +- .../core/ui/component/LikedTracksCard.kt | 4 +- .../core/ui/component/cards/PlayableCard.kt | 6 +- .../ui/component/cards/PlayableListTile.kt | 4 +- .../spotube/modules/artist/ArtistScreen.kt | 376 ++++++++++-------- .../modules/devices/RemoteControlScreen.kt | 254 +++++++----- .../modules/shell/AppExpandedPlayer.kt | 142 ++++--- .../spotube/modules/shell/AppLargePlayer.kt | 27 +- 9 files changed, 512 insertions(+), 334 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..4c36a5e5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "compose-hot-reload": { + "command": "./gradlew", + "args": [ + "--no-daemon", + "--quiet", + "--console=plain", + "hotMcpServer" + ] + }, + "studio": { + "url": "http://127.0.0.1:64342/stream", + "type": "http" + } + } +} \ No newline at end of file diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index a8304a54..f733b628 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -13,19 +13,9 @@ "hotMcpServer" ] }, - "android-studio-mcp": { - "type": "local", - "environment": { - "IJ_MCP_SERVER_PORT": "64342" - }, - "enabled": true, - "disabled": false, - "command": [ - "/opt/android-studio/jbr/bin/java", - "-classpath", - "/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/mcpserver.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.modelcontextprotocol.kotlin.sdk.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.github.oshai.kotlin.logging.jvm.jar:/opt/android-studio/lib/util-8.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.coroutines.core.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.cio.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.jar:/opt/android-studio/lib/intellij.libraries.ktor.network.tls.jar:/opt/android-studio/lib/intellij.libraries.ktor.io.jar:/opt/android-studio/lib/intellij.libraries.ktor.utils.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.io.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.core.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.json.jar", - "com.intellij.mcpserver.stdio.McpStdioRunnerKt" - ] + "studio": { + "url": "http://127.0.0.1:64342/stream", + "type": "remote" } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt index 456de393..4eacf176 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope @@ -158,7 +159,8 @@ fun LikedTracksCard( Iconsax.IconsaxPauseCircle } else Iconsax.Play, contentDescription = stringResource(Res.string.player_play_content_description), - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), + tint = LocalTextStyle.current.color ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt index 05edce14..bd79c9e0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt @@ -21,6 +21,7 @@ package dev.krtirtho.spotube.core.ui.component.cards import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle +import com.composeunstyled.LocalTextStyle import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn @@ -143,7 +144,7 @@ fun PlayableCard( imageVector = Iconsax.IconsaxAddSquare, contentDescription = stringResource( Res.string.player_add_to_queue_content_description - ) + ), ) } Spacer(modifier = Modifier.height(8.dp)) @@ -165,7 +166,8 @@ fun PlayableCard( } else { Res.string.player_play_content_description } - ) + ), + tint = LocalTextStyle.current.color ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt index 253f2155..cff5697b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.composeunstyled.LocalTextStyle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope @@ -130,7 +131,8 @@ fun PlayableListTile( ) { MillenniumIcon( if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - stringResource(if (isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description) + stringResource(if (isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description), + tint = LocalTextStyle.current.color ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index a7ab61be..51934952 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.foundation.shape.CircleShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -117,87 +118,67 @@ fun ArtistScreen( Column( modifier = Modifier .fillMaxSize() - .background(MillenniumTheme.colors.background)) { + .background(MillenniumTheme.colors.background) + ) { ApplicationMainBar() Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - when (val currentState = state) { - is ArtistScreenState.Loading -> { - ArtistLoadingContent() - } + when (val currentState = state) { + is ArtistScreenState.Loading -> { + ArtistLoadingContent() + } - is ArtistScreenState.Error -> { - ArtistErrorContent( - message = currentState.message, - onRetry = { viewModel.refresh() } - ) - } + is ArtistScreenState.Error -> { + ArtistErrorContent( + message = currentState.message, + onRetry = { viewModel.refresh() } + ) + } - is ArtistScreenState.Loaded -> { - val listState = rememberLazyListState() + is ArtistScreenState.Loaded -> { + val listState = rememberLazyListState() - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .padding(bottom = LocalAppShellBottomInset.current + 24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp)) { - item { - ArtistHeaderCard( - artist = currentState.artist, - isSaved = savedArtistIds.contains(currentState.artist.id), - onFollowClick = viewModel::toggleSavedArtist, - isBlacklisted = currentState.artist.id in blacklistedArtistIds, - onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) - } - - item { - TopTracksHeader( - onPlay = viewModel::playTopTracks, - onAddToQueue = viewModel::addTopTracksToQueue) - } - - item { - TrackList( - tracks = currentState.topTracks, - error = null, - hasMore = false, - isLoading = false, - isLoadingNextPage = false, - currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, - isCurrentTrackPlaying = playerState == PlayerState.PLAYING, - onTrackClick = viewModel::playTopTracksFromTrack, - onTrackOptionsAction = viewModel::handleTrackOptionsAction, - trackOptionsState = trackOptionsContext::stateFor, - onArtistClick = { trackArtist -> - navigationCommands.navigateTo( - Routes.Artist( - trackArtist.id - ) - ) - }, - onAlbumClick = { album -> - navigationCommands.navigateTo( - Routes.Album( - album.id - ) - ) - }, - onLoadNextPage = {}, - simplified = true, - scrollable = false, - onBulkDownload = viewModel::downloadTracks, - onBulkAddToQueue = viewModel::addTracksToQueue, - onBulkPlayNext = viewModel::playTracksNext, - onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, - onBulkAddToJam = viewModel::addTracksToJam, - isInJam = jamActive, - isJamGuest = isJamGuest) - } - - if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(bottom = LocalAppShellBottomInset.current + 24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { item { - AlbumsSection( - albums = currentState.albums, + ArtistHeaderCard( + artist = currentState.artist, + isSaved = savedArtistIds.contains(currentState.artist.id), + onFollowClick = viewModel::toggleSavedArtist, + isBlacklisted = currentState.artist.id in blacklistedArtistIds, + onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) + } + + item { + TopTracksHeader( + onPlay = viewModel::playTopTracks, + onAddToQueue = viewModel::addTopTracksToQueue + ) + } + + item { + TrackList( + tracks = currentState.topTracks, + error = null, + hasMore = false, + isLoading = false, + isLoadingNextPage = false, + currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, + isCurrentTrackPlaying = playerState == PlayerState.PLAYING, + onTrackClick = viewModel::playTopTracksFromTrack, + onTrackOptionsAction = viewModel::handleTrackOptionsAction, + trackOptionsState = trackOptionsContext::stateFor, + onArtistClick = { trackArtist -> + navigationCommands.navigateTo( + Routes.Artist( + trackArtist.id + ) + ) + }, onAlbumClick = { album -> navigationCommands.navigateTo( Routes.Album( @@ -205,49 +186,74 @@ fun ArtistScreen( ) ) }, - onLoadMore = { viewModel.loadMoreAlbums() }) + onLoadNextPage = {}, + simplified = true, + scrollable = false, + onBulkDownload = viewModel::downloadTracks, + onBulkAddToQueue = viewModel::addTracksToQueue, + onBulkPlayNext = viewModel::playTracksNext, + onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest + ) } - } - if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) { - item { - RelatedArtistsSection( - artists = currentState.relatedArtists, - onArtistClick = { artist -> - navigationCommands.navigateTo( - Routes.Artist( - artist.id + if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { + item { + AlbumsSection( + albums = currentState.albums, + onAlbumClick = { album -> + navigationCommands.navigateTo( + Routes.Album( + album.id + ) ) - ) - }, - onLoadMore = { viewModel.loadMoreRelatedArtists() }) + }, + onLoadMore = { viewModel.loadMoreAlbums() }) + } } - } - if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) { - item { - FeaturedPlaylistsSection( - playlists = currentState.featuredPlaylists, - onPlaylistClick = { playlist -> - navigationCommands.navigateTo( - Routes.Playlist( - playlist.id + if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) { + item { + RelatedArtistsSection( + artists = currentState.relatedArtists, + onArtistClick = { artist -> + navigationCommands.navigateTo( + Routes.Artist( + artist.id + ) ) - ) - }, - onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }) + }, + onLoadMore = { viewModel.loadMoreRelatedArtists() }) + } + } + + if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) { + item { + FeaturedPlaylistsSection( + playlists = currentState.featuredPlaylists, + onPlaylistClick = { playlist -> + navigationCommands.navigateTo( + Routes.Playlist( + playlist.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }) + } } } } } } - } AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist) + onPlaylistSelected = viewModel::addTracksToPlaylist + ) } } @@ -256,41 +262,50 @@ private fun ArtistLoadingContent() { Box( modifier = Modifier .fillMaxSize(), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(24.dp)) { + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp)) { + .padding(horizontal = 16.dp) + ) { Box( modifier = Modifier .size(200.dp) .clip(CircleShape) - .background(AppTheme.colors.surfaceContainerHighest)) + .background(AppTheme.colors.surfaceContainerHighest) + ) } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp)) { + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp)) { + .padding(horizontal = 16.dp) + ) { Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp)) + .padding(vertical = 8.dp) + ) } LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp)) { + contentPadding = PaddingValues(horizontal = 16.dp) + ) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -302,18 +317,22 @@ private fun ArtistLoadingContent() { @Composable private fun ArtistErrorContent( message: String, - onRetry: () -> Unit) { + onRetry: () -> Unit +) { Box( modifier = Modifier .fillMaxSize(), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp)) { + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { Text( text = message, color = AppTheme.colors.error, - style = AppTheme.typography.bodyLarge) + style = AppTheme.typography.bodyLarge + ) MillenniumButton(onClick = onRetry) { Text("Retry") } @@ -327,34 +346,40 @@ private fun ArtistHeaderCard( isSaved: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, - onBlacklistClick: () -> Unit) { + onBlacklistClick: () -> Unit +) { Box(modifier = Modifier.fillMaxWidth()) { val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium MillenniumCard( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp)) { + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { if (isCompact) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), - horizontalAlignment = Alignment.CenterHorizontally) { + horizontalAlignment = Alignment.CenterHorizontally + ) { ArtistAvatar( artist = artist, - size = 180.dp) + size = 180.dp + ) ArtistMeta( artist = artist, - isCompact = true) + isCompact = true + ) ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick) + onBlacklistClick = onBlacklistClick + ) } } else { Row( @@ -362,23 +387,28 @@ private fun ArtistHeaderCard( .fillMaxWidth() .padding(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.Top) { + verticalAlignment = Alignment.Top + ) { ArtistAvatar( artist = artist, - size = 220.dp) + size = 220.dp + ) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp)) { + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { ArtistMeta( artist = artist, - isCompact = false) + isCompact = false + ) ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick) + onBlacklistClick = onBlacklistClick + ) } } } @@ -389,26 +419,30 @@ private fun ArtistHeaderCard( @Composable private fun ArtistAvatar( artist: MetadataArtist.Detailed, - size: androidx.compose.ui.unit.Dp) { + size: androidx.compose.ui.unit.Dp +) { Box( modifier = Modifier .size(size) .clip(CircleShape) .background(AppTheme.colors.surfaceContainerHighest), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() if (imageUrl.isNotBlank()) { AsyncImage( model = imageUrl, contentDescription = artist.name, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) } else { MillenniumIcon( imageVector = Iconsax.User, contentDescription = artist.name, tint = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.size(size * 0.4f)) + modifier = Modifier.size(size * 0.4f) + ) } } } @@ -416,18 +450,21 @@ private fun ArtistAvatar( @Composable private fun ArtistMeta( artist: MetadataArtist.Detailed, - isCompact: Boolean) { + isCompact: Boolean +) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp)) { + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { Text( text = artist.name, style = if (isCompact) AppTheme.typography.headlineSmall else AppTheme.typography.headlineLarge, fontWeight = FontWeight.SemiBold, textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, maxLines = 2, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) Text( text = buildString { @@ -439,7 +476,8 @@ private fun ArtistMeta( }, style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start) + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start + ) artist.biography?.takeIf { it.isNotBlank() }?.let { Text( @@ -448,7 +486,8 @@ private fun ArtistMeta( color = AppTheme.colors.onSurfaceVariant, maxLines = if (isCompact) 4 else 6, overflow = TextOverflow.Ellipsis, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start) + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start + ) } } } @@ -458,19 +497,23 @@ private fun ArtistHeaderActions( isSaved: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, - onBlacklistClick: () -> Unit) { + onBlacklistClick: () -> Unit +) { Row( horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { if (isSaved) { MillenniumButton( onClick = onFollowClick, - style = MillenniumButtonStyle.Secondary) { + style = MillenniumButtonStyle.Secondary + ) { Text( "Following", textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) } } else { MillenniumButton(onClick = onFollowClick) { @@ -484,19 +527,23 @@ private fun ArtistHeaderActions( if (isBlacklisted) { MillenniumIconButton( onClick = onBlacklistClick, - style = MillenniumIconButtonStyle.Secondary) { + style = MillenniumIconButtonStyle.Secondary + ) { MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = "Remove from blacklist", - tint = AppTheme.colors.error) + tint = AppTheme.colors.error + ) } } else { MillenniumIconButton( onClick = onBlacklistClick, - style = MillenniumIconButtonStyle.Secondary) { + style = MillenniumIconButtonStyle.Secondary + ) { MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Add to blacklist") + contentDescription = "Add to blacklist" + ) } } } @@ -506,32 +553,40 @@ private fun ArtistHeaderActions( @Composable private fun TopTracksHeader( onPlay: () -> Unit, - onAddToQueue: () -> Unit) { + onAddToQueue: () -> Unit +) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( text = "Top Tracks", style = AppTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold) + fontWeight = FontWeight.SemiBold + ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumIconButton( onClick = onPlay, - style = MillenniumIconButtonStyle.Primary) { + style = MillenniumIconButtonStyle.Primary + ) { MillenniumIcon( imageVector = Iconsax.IconsaxPlay, - contentDescription = "Play top tracks") + contentDescription = "Play top tracks", + tint = LocalTextStyle.current.color + ) } MillenniumIconButton( onClick = onAddToQueue, - style = MillenniumIconButtonStyle.Secondary) { + style = MillenniumIconButtonStyle.Secondary + ) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add top tracks to queue") + contentDescription = "Add top tracks to queue" + ) } } } @@ -541,7 +596,8 @@ private fun TopTracksHeader( private fun AlbumsSection( albums: List, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, - onLoadMore: () -> Unit) { + onLoadMore: () -> Unit +) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -566,7 +622,8 @@ private fun AlbumsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top) { + verticalAlignment = Alignment.Top + ) { items(albums, key = { it.id }) { album -> AlbumCard(album = album) } @@ -577,7 +634,8 @@ private fun AlbumsSection( private fun RelatedArtistsSection( artists: List, onArtistClick: (MetadataArtist.Basic) -> Unit, - onLoadMore: () -> Unit) { + onLoadMore: () -> Unit +) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -602,7 +660,8 @@ private fun RelatedArtistsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top) { + verticalAlignment = Alignment.Top + ) { items(artists, key = { it.id }) { artist -> ArtistCard(artist = artist) } @@ -613,7 +672,8 @@ private fun RelatedArtistsSection( private fun FeaturedPlaylistsSection( playlists: List, onPlaylistClick: (MetadataPlaylist) -> Unit, - onLoadMore: () -> Unit) { + onLoadMore: () -> Unit +) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -638,7 +698,8 @@ private fun FeaturedPlaylistsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top) { + verticalAlignment = Alignment.Top + ) { items(playlists, key = { it.id }) { playlist -> PlaylistCard(playlist = playlist) } @@ -651,7 +712,8 @@ private fun SectionHeader(title: String) { text = title, style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp)) + modifier = Modifier.padding(horizontal = 16.dp) + ) } private fun formatFollowers(count: Int?): String { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index 30cb314d..7ce08e4c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.foundation.verticalScroll import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -86,7 +87,8 @@ import kotlin.time.Duration.Companion.milliseconds @Composable fun RemoteControlScreen( - onDisconnect: () -> Unit) { + onDisconnect: () -> Unit +) { val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() @@ -98,18 +100,21 @@ fun RemoteControlScreen( // stretch awkwardly on large screens. Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter) { + contentAlignment = Alignment.TopCenter + ) { Column( modifier = Modifier .fillMaxHeight() - .background(MillenniumTheme.colors.background)) { + .background(MillenniumTheme.colors.background) + ) { ApplicationMainBar( title = { Text("Remote Control") }, backButton = true, actions = { MillenniumIconButton( onClick = viewModel::toggleQueueVisibility, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.IconsaxMusicFilter, contentDescription = "Queue", @@ -117,66 +122,72 @@ fun RemoteControlScreen( AppTheme.colors.primary } else { AppTheme.colors.onSurfaceVariant - }) + } + ) } MillenniumIconButton( onClick = { viewModel.disconnect() onDisconnect() }, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Disconnect") + contentDescription = "Disconnect" + ) } } ) Box(modifier = Modifier.weight(1f)) { - when (connectionState) { - is ConnectionState.Connected -> { - RemoteControlContent( - playerState = playerState, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onSetVolume = viewModel::setVolume, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(bottom = shellBottomInset) - ) - } - is ConnectionState.Connecting -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connecting...") + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(bottom = shellBottomInset) + ) + } + + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } + } + + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } } } - is ConnectionState.Disconnected -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Disconnected") - } - } - is ConnectionState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connection error: ${(connectionState as ConnectionState.Error).message}") - } - } - } } } } @@ -198,15 +209,18 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(bottom = shellBottomInset)) { + .padding(bottom = shellBottomInset) + ) { QueueSheet( isVisible = isQueueVisible, onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize()) { + modifier = Modifier.fillMaxSize() + ) { RemoteQueueSection( queueState = queueState, onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem) + onRemoveQueueItem = viewModel::removeQueueItem + ) } } } @@ -221,15 +235,17 @@ private fun RemoteControlContent( onSetVolume: (Float) -> Unit, onToggleShuffle: () -> Unit, onCycleLoopMode: () -> Unit, - modifier: Modifier = Modifier) { - Box (modifier = Modifier.fillMaxSize()) { + modifier: Modifier = Modifier +) { + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = modifier .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp) .widthIn(max = 480.dp) .align(Alignment.TopCenter), - horizontalAlignment = Alignment.CenterHorizontally) { + horizontalAlignment = Alignment.CenterHorizontally + ) { Spacer(modifier = Modifier.height(32.dp)) // Album art @@ -238,22 +254,26 @@ private fun RemoteControlContent( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)) - .background(AppTheme.colors.surfaceVariant)) { + .background(AppTheme.colors.surfaceVariant) + ) { if (playerState.currentTrackCoverUrl != null) { AsyncImage( model = playerState.currentTrackCoverUrl, contentDescription = "Album cover", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) } else { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { MillenniumIcon( imageVector = Iconsax.IconsaxMusicFilter, contentDescription = null, tint = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.size(48.dp)) + modifier = Modifier.size(48.dp) + ) } } } @@ -263,14 +283,16 @@ private fun RemoteControlContent( // Track info Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally) { + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( text = playerState.currentTrackTitle ?: "Unknown Track", style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) Spacer(modifier = Modifier.height(8.dp)) @@ -280,7 +302,8 @@ private fun RemoteControlContent( color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) if (playerState.currentTrackAlbum != null) { Spacer(modifier = Modifier.height(4.dp)) @@ -290,7 +313,8 @@ private fun RemoteControlContent( color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) } } @@ -298,25 +322,30 @@ private fun RemoteControlContent( // Seek bar Column( - modifier = Modifier.fillMaxWidth()) { + modifier = Modifier.fillMaxWidth() + ) { MillenniumSlider( value = playerState.positionMs.toFloat(), onValueChange = { onSeek(it.toLong()) }, label = "Seek", valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween) { + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( text = formatDuration(playerState.positionMs), style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) Text( text = formatDuration(playerState.durationMs), style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } } @@ -326,11 +355,13 @@ private fun RemoteControlContent( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { // Shuffle MillenniumIconButton( onClick = onToggleShuffle, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle", @@ -338,23 +369,27 @@ private fun RemoteControlContent( AppTheme.colors.primary } else { AppTheme.colors.onSurfaceVariant - }) + } + ) } // Skip previous MillenniumIconButton( onClick = onSkipPrevious, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.IconsaxPrevious, contentDescription = "Previous", - modifier = Modifier.size(32.dp)) + modifier = Modifier.size(32.dp) + ) } // Play/Pause MillenniumIconButton( onClick = onTogglePlayPause, - style = MillenniumIconButtonStyle.Primary) { + style = MillenniumIconButtonStyle.Primary + ) { MillenniumIcon( imageVector = if (playerState.isPlaying) { Iconsax.IconsaxPause @@ -362,23 +397,28 @@ private fun RemoteControlContent( Iconsax.IconsaxPlay }, contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp)) + modifier = Modifier.size(40.dp), + tint = LocalTextStyle.current.color + ) } // Skip next MillenniumIconButton( onClick = onSkipNext, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.IconsaxNext, contentDescription = "Next", - modifier = Modifier.size(32.dp)) + modifier = Modifier.size(32.dp) + ) } // Loop mode MillenniumIconButton( onClick = onCycleLoopMode, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = Iconsax.IconsaxRepeateMusic, contentDescription = "Loop mode", @@ -386,7 +426,8 @@ private fun RemoteControlContent( AppTheme.colors.primary } else { AppTheme.colors.onSurfaceVariant - }) + } + ) } } @@ -396,19 +437,22 @@ private fun RemoteControlContent( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp)) { + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { MillenniumIcon( imageVector = Iconsax.IconsaxVolumeHigh, contentDescription = "Volume", tint = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.size(24.dp)) + modifier = Modifier.size(24.dp) + ) MillenniumSlider( value = playerState.volume, onValueChange = onSetVolume, label = "Volume", valueRange = 0f..1f, - modifier = Modifier.weight(1f)) + modifier = Modifier.weight(1f) + ) } Spacer(modifier = Modifier.height(32.dp)) @@ -420,26 +464,31 @@ private fun RemoteControlContent( private fun RemoteQueueSection( queueState: RemoteQueueState, onPlayQueueItem: (Int) -> Unit, - onRemoveQueueItem: (String) -> Unit) { + onRemoveQueueItem: (String) -> Unit +) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp)) { + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { Text( text = "Queue", - style = AppTheme.typography.titleLarge) + style = AppTheme.typography.titleLarge + ) if (queueState.entries.isEmpty()) { Text( text = "No queue entries", style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } else { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp)) { + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { itemsIndexed( items = queueState.entries, key = { index, entry -> "${entry.mediaUrl}@$index" }) { index, entry -> @@ -461,7 +510,8 @@ private fun RemoteQueueItemRow( index: Int, isCurrent: Boolean, onPlayClick: () -> Unit, - onRemoveClick: () -> Unit) { + onRemoveClick: () -> Unit +) { var showMenu by remember { mutableStateOf(false) } MillenniumListTile( @@ -474,18 +524,21 @@ private fun RemoteQueueItemRow( .size(48.dp) .clip(AppTheme.shapes.small) .background(AppTheme.colors.surfaceVariant), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { if (entry.coverUrl != null) { AsyncImage( model = entry.coverUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) } else { Text( text = "${index + 1}", style = AppTheme.typography.labelMedium, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } } }, @@ -499,7 +552,8 @@ private fun RemoteQueueItemRow( AppTheme.colors.onSecondaryContainer } else { AppTheme.colors.onSurface - }) + } + ) }, subtitle = { Text( @@ -507,13 +561,15 @@ private fun RemoteQueueItemRow( style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) }, trailing = { Text( text = formatDuration(entry.durationMs), style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) MillenniumDropdownMenu( expanded = showMenu, @@ -526,11 +582,13 @@ private fun RemoteQueueItemRow( anchor = { MillenniumIconButton( onClick = { showMenu = true }, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( Iconsax.Iconsax3DotsMore, contentDescription = "More options", - modifier = Modifier.size(18.dp)) + modifier = Modifier.size(18.dp) + ) } }) } @@ -542,4 +600,4 @@ private fun formatDuration(ms: Long): String { val minutes = duration.inWholeMinutes val seconds = duration.inWholeSeconds % 60 return "$minutes:${seconds.toString().padStart(2, '0')}" -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 228141aa..75de7d32 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -65,6 +65,7 @@ import com.composeunstyled.rememberModalBottomSheetState import androidx.compose.ui.graphics.SolidColor import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -158,7 +159,8 @@ fun AppExpandedPlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - )) { + ) +) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val jamRoomService: JamRoomService = koinInject() val isJamGuest by jamRoomService.role @@ -185,7 +187,8 @@ fun AppExpandedPlayer( var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } val moreOptionsSheetState = rememberModalBottomSheetState( - initialDetent = SheetDetent.FullyExpanded) + initialDetent = SheetDetent.FullyExpanded + ) PlayerOptionDialogs(viewModel = playerOptionsViewModel) @@ -262,7 +265,8 @@ fun AppExpandedPlayer( contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth()) { + modifier = Modifier.fillMaxWidth() + ) { item { OptionTile( icon = Iconsax.SwapHorizontal2, @@ -297,7 +301,8 @@ fun AppExpandedPlayer( shape = RoundedCornerShape(14.dp), borderColor = MillenniumTheme.colors.border, borderWidth = 1.dp, - shadowElevation = 2.dp) + shadowElevation = 2.dp + ) .clickable { playerOptionsViewModel.dismissMoreOptionsSheet() when (status) { @@ -337,14 +342,16 @@ fun AppExpandedPlayer( .fillMaxSize() .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { + verticalArrangement = Arrangement.Center + ) { if (status is DownloadStatus.Downloading) { DownloadProgressIcon( track = currentTrack, icon = Iconsax.IconsaxDirectboxReceive, contentDescription = "Download", modifier = Modifier.size(28.dp), - tint = AppTheme.colors.onSurface) + tint = AppTheme.colors.onSurface + ) } else { val icon = when (status) { is DownloadStatus.Completed -> Iconsax.IconsaxCheckCircle @@ -361,7 +368,8 @@ fun AppExpandedPlayer( is DownloadStatus.Completed -> AppTheme.colors.primary is DownloadStatus.Failed -> AppTheme.colors.error else -> AppTheme.colors.onSurface - }) + } + ) } Spacer(modifier = Modifier.height(8.dp)) Text( @@ -370,7 +378,8 @@ fun AppExpandedPlayer( textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, - color = statusColor) + color = statusColor + ) } } } @@ -416,7 +425,8 @@ fun AppExpandedPlayer( .windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally) { + horizontalAlignment = Alignment.CenterHorizontally + ) { Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier @@ -425,18 +435,24 @@ fun AppExpandedPlayer( ) { MillenniumIconButton( onClick = onCollapse, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxArrowDown4, + contentDescription = "Collapse player" + ) } Text( text = "Now Playing", style = AppTheme.typography.titleMedium, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, - maxLines = 1) + maxLines = 1 + ) MillenniumIconButton( onClick = playerOptionsViewModel::showMoreOptionsSheet, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") } } @@ -445,7 +461,8 @@ fun AppExpandedPlayer( modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 24.dp), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { Box( modifier = Modifier .fillMaxWidth(0.84f) @@ -458,7 +475,8 @@ fun AppExpandedPlayer( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) } } @@ -471,16 +489,19 @@ fun AppExpandedPlayer( ) { PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel) + savedTracksViewModel = savedTracksViewModel + ) Column( modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally) { + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( text = playerUiState.title, style = AppTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center + ) PlayerArtistsText( artists = playerUiState.artistList, onArtistClick = { artistId -> @@ -488,7 +509,8 @@ fun AppExpandedPlayer( }, style = AppTheme.typography.bodyLarge, color = AppTheme.colors.onSurfaceVariant, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center + ) } MillenniumIconButton( onClick = onQueue, @@ -518,26 +540,31 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(top = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { Text( text = formatPlayerTime(playerUiState.position), style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) Text( text = formatPlayerTime(playerUiState.displayDuration), style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } Row( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp, bottom = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { MillenniumIconButton( onClick = ::onShuffleToggle, enabled = !isJamGuest, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", @@ -551,27 +578,33 @@ fun AppExpandedPlayer( MillenniumIconButton( onClick = ::onSkipPrevious, enabled = !isJamGuest, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } MillenniumIconButton( onClick = ::onPlayPause, - style = MillenniumIconButtonStyle.Primary) { + style = MillenniumIconButtonStyle.Primary + ) { MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(30.dp)) + modifier = Modifier.size(30.dp), + tint = LocalTextStyle.current.color + ) } MillenniumIconButton( onClick = ::onSkipNext, enabled = !isJamGuest, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon(Iconsax.IconsaxNext, contentDescription = "Next") } MillenniumIconButton( onClick = ::onLoopToggle, enabled = !isJamGuest, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic @@ -594,12 +627,14 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(bottom = 16.dp), audioPlayer = audioPlayer, - onExpand = onExpandLyrics) + onExpand = onExpandLyrics + ) } MillenniumSnackbarHost( hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter)) + modifier = Modifier.align(Alignment.BottomCenter) + ) } } @@ -607,13 +642,15 @@ fun AppExpandedPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope? +): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope) + animatedVisibilityScope = animatedVisibilityScope + ) } } @@ -660,24 +697,30 @@ private fun LyricsPreviewCard( .millenniumSurface( background = SolidColor(AppTheme.colors.surfaceContainerHighest), shape = RoundedCornerShape(16.dp), - shadowElevation = 2.dp) - .clickable(onClick = onExpand)) { + shadowElevation = 2.dp + ) + .clickable(onClick = onExpand) + ) { Column(modifier = Modifier.padding(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { Text( text = "Lyrics", style = AppTheme.typography.labelMedium, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) MillenniumIconButton( onClick = onExpand, - style = MillenniumIconButtonStyle.Quiet) { + style = MillenniumIconButtonStyle.Quiet + ) { MillenniumIcon( Iconsax.IconsaxArrowSquareUp, contentDescription = "Expand lyrics", - tint = AppTheme.colors.onSurfaceVariant) + tint = AppTheme.colors.onSurfaceVariant + ) } } @@ -687,7 +730,8 @@ private fun LyricsPreviewCard( .fillMaxWidth() .height(120.dp), contentPadding = PaddingValues(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp)) { + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { val displayLines = syncedLyrics.take(6) itemsIndexed(displayLines) { index, line -> val isCurrent = @@ -707,7 +751,8 @@ private fun LyricsPreviewCard( ), maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center + ) } } } @@ -719,21 +764,25 @@ private fun OptionTile( icon: ImageVector, label: String, onClick: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { MillenniumCard( modifier = modifier .aspectRatio(1f) .clickable(onClick = onClick), - contentPadding = PaddingValues(12.dp)) { + contentPadding = PaddingValues(12.dp) + ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { + verticalArrangement = Arrangement.Center + ) { MillenniumIcon( imageVector = icon, contentDescription = label, modifier = Modifier.size(28.dp), - tint = AppTheme.colors.onSurface) + tint = AppTheme.colors.onSurface + ) Spacer(modifier = Modifier.height(8.dp)) Text( text = label, @@ -741,7 +790,8 @@ private fun OptionTile( textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index e2a87728..0b034497 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -18,12 +18,6 @@ package dev.krtirtho.spotube.modules.shell -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumDropdownMenu -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumSeparator -import org.spotube.millennium.composables.MillenniumSlider import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,12 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme - -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,13 +44,14 @@ import androidx.compose.runtime.setValue 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.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.composeunstyled.AnchorSide +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect @@ -74,15 +64,14 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter import dev.krtirtho.spotube.resources.iconsax.IconsaxNext @@ -96,7 +85,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow -import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -104,6 +92,12 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSlider import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_go_to_album import spotube.composeapp.generated.resources.player_sleep_timer @@ -346,7 +340,8 @@ fun AppLargePlayer( ) { MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause" + contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", + tint = LocalTextStyle.current.color ) } MillenniumIconButton( From 0e0e24209526f6682815d2b83a99b51f42e207e0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Oct 2026 13:18:26 +0600 Subject: [PATCH 050/132] feat(navigation): add custom navigation transition specifications for push and pop actions --- .../kotlin/dev/krtirtho/spotube/App.kt | 8 +- .../spotube/core/navigation/NavTransitions.kt | 76 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 0cf66c03..fb59f5ec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -37,6 +37,9 @@ import dev.krtirtho.spotube.core.DebugConfig import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES +import dev.krtirtho.spotube.core.navigation.navPopTransitionSpec +import dev.krtirtho.spotube.core.navigation.navPredictivePopTransitionSpec +import dev.krtirtho.spotube.core.navigation.navPushTransitionSpec import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope @@ -210,7 +213,10 @@ fun App( NavDisplay( modifier = Modifier.fillMaxSize(), onBack = navigator::pop, - entries = navigationState.toEntries(koinEntryProvider()) + entries = navigationState.toEntries(koinEntryProvider()), + transitionSpec = navPushTransitionSpec, + popTransitionSpec = navPopTransitionSpec, + predictivePopTransitionSpec = navPredictivePopTransitionSpec ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt new file mode 100644 index 00000000..742ac190 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt @@ -0,0 +1,76 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.ui.unit.IntOffset +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene + +private const val TRANSITION_DURATION_MILLIS = 300 +private const val SLIDE_FRACTION = 0.08f +private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + +// NavEntry.contentKey defaults to key.toString() +private val topLevelContentKeys = TOP_LEVEL_ROUTES.map { it.toString() }.toSet() + +/** + * The tab a scene belongs to: the last top-level route in its stack. Switching tabs changes + * this, while pushing/popping pages inside a tab doesn't. + */ +private fun Scene.tabRoot(): Any? = + (previousEntries + entries).lastOrNull { it.contentKey in topLevelContentKeys }?.contentKey + +private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean = + initialState.tabRoot() != targetState.tabRoot() + +/** Material shared-axis (X) style: a short horizontal slide combined with a cross-fade. */ +private fun AnimatedContentTransitionScope>.sharedAxisX( + forward: Boolean +): ContentTransform { + if (isTabSwitch()) return ContentTransform(EnterTransition.None, ExitTransition.None) + + val direction = if (forward) 1 else -1 + val slideSpec = tween( + TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate + ) + val enter = slideInHorizontally(slideSpec) { (it * SLIDE_FRACTION).toInt() * direction } + + fadeIn(tween(TRANSITION_DURATION_MILLIS / 2, delayMillis = TRANSITION_DURATION_MILLIS / 6)) + val exit = slideOutHorizontally(slideSpec) { -(it * SLIDE_FRACTION).toInt() * direction } + + fadeOut(tween(TRANSITION_DURATION_MILLIS / 3)) + return enter togetherWith exit +} + +val navPushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { sharedAxisX(forward = true) } + +val navPopTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { sharedAxisX(forward = false) } + +val navPredictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { sharedAxisX(forward = false) } From 9f79505e1c415674cd2753fbf0ff2121a25e913d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Oct 2026 14:07:56 +0600 Subject: [PATCH 051/132] feat(i18n): replace hardcoded strings with localized resources across multiple components --- .../spotube/core/i18n/AppLocale.android.kt | 42 + .../spotube/media/MediaBrowseHelper.kt | 33 +- .../krtirtho/spotube/media/PlaybackService.kt | 14 +- .../composeResources/values-ar/strings.xml | 962 ++++++++++++++++++ .../composeResources/values-bn/strings.xml | 938 +++++++++++++++++ .../composeResources/values-de/strings.xml | 938 +++++++++++++++++ .../composeResources/values-es/strings.xml | 944 +++++++++++++++++ .../composeResources/values-fa/strings.xml | 938 +++++++++++++++++ .../composeResources/values-fr/strings.xml | 944 +++++++++++++++++ .../composeResources/values-hi/strings.xml | 937 +++++++++++++++++ .../composeResources/values-id/strings.xml | 932 +++++++++++++++++ .../composeResources/values-it/strings.xml | 944 +++++++++++++++++ .../composeResources/values-ja/strings.xml | 932 +++++++++++++++++ .../composeResources/values-ko/strings.xml | 932 +++++++++++++++++ .../composeResources/values-nl/strings.xml | 938 +++++++++++++++++ .../composeResources/values-pl/strings.xml | 950 +++++++++++++++++ .../composeResources/values-pt/strings.xml | 944 +++++++++++++++++ .../composeResources/values-ru/strings.xml | 950 +++++++++++++++++ .../composeResources/values-th/strings.xml | 932 +++++++++++++++++ .../composeResources/values-tr/strings.xml | 938 +++++++++++++++++ .../composeResources/values-uk/strings.xml | 950 +++++++++++++++++ .../composeResources/values-vi/strings.xml | 932 +++++++++++++++++ .../values-zh-rTW/strings.xml | 932 +++++++++++++++++ .../composeResources/values-zh/strings.xml | 932 +++++++++++++++++ .../composeResources/values/strings.xml | 479 ++++++++- .../kotlin/dev/krtirtho/spotube/App.kt | 155 +-- .../krtirtho/spotube/core/i18n/AppLocale.kt | 60 ++ .../spotube/core/jam/JamRoomClient.kt | 22 +- .../spotube/core/jam/JamRoomService.kt | 12 +- .../core/remote/ConnectionRequestDialog.kt | 17 +- .../core/remote/RemoteControlClient.kt | 19 +- .../core/remote/RemotePlaybackController.kt | 9 +- .../ui/component/AdaptiveDialogBottomSheet.kt | 4 +- .../core/ui/component/ApplicationMainBar.kt | 6 +- .../core/ui/component/CollectionDetails.kt | 24 +- .../core/ui/component/CollectionView.kt | 8 +- .../spotube/core/ui/component/ErrorDisplay.kt | 22 +- .../core/ui/component/FeaturedCarousel.kt | 4 +- .../spotube/core/ui/component/TrackList.kt | 41 +- .../spotube/core/ui/component/TrackOptions.kt | 29 +- .../spotube/core/ui/component/UserCard.kt | 4 +- .../core/webview/PlatformWebviewScreen.kt | 8 +- .../spotube/modules/album/AlbumScreen.kt | 8 +- .../spotube/modules/album/AlbumViewModel.kt | 7 +- .../spotube/modules/artist/ArtistScreen.kt | 38 +- .../spotube/modules/artist/ArtistViewModel.kt | 6 +- .../modules/blacklist/BlacklistScreen.kt | 24 +- .../spotube/modules/devices/DevicesScreen.kt | 44 +- .../modules/devices/DevicesViewModel.kt | 26 +- .../modules/devices/PlayDestinationPicker.kt | 37 +- .../modules/devices/RemoteControlScreen.kt | 72 +- .../modules/downloads/DownloadManager.kt | 16 +- .../spotube/modules/home/HomeScreen.kt | 9 +- .../modules/home/HomeScreenViewModel.kt | 9 +- .../modules/jam/PlayDestinationPicker.kt | 19 +- .../spotube/modules/library/LibraryTab.kt | 12 +- .../library/album/LibraryAlbumsViewModel.kt | 7 +- .../library/artist/LibraryArtistsViewModel.kt | 7 +- .../library/playlist/AddToPlaylistPicker.kt | 26 +- .../playlist/LibraryPlaylistsViewModel.kt | 7 +- .../library/playlist/PlaylistFormSheet.kt | 56 +- .../spotube/modules/lyrics/LyricsScreen.kt | 22 +- .../playlist/AddTracksToPlaylistDialog.kt | 28 +- .../modules/playlist/PlaylistScreen.kt | 11 +- .../modules/playlist/PlaylistViewModel.kt | 7 +- .../spotube/modules/plugin/PluginManager.kt | 116 ++- .../spotube/modules/plugin/PluginScreen.kt | 81 +- .../components/DefaultAbilityPluginGrid.kt | 6 +- .../plugin/components/PluginInstallDialog.kt | 21 +- .../components/PluginPermissionsDialog.kt | 8 +- .../modules/saved_tracks/SavedTracksScreen.kt | 9 +- .../saved_tracks/SavedTracksViewModel.kt | 8 +- .../modules/search/SearchRepository.kt | 17 +- .../spotube/modules/search/SearchScreen.kt | 107 +- .../modules/search/SearchScreenViewModel.kt | 32 +- .../modules/settings/JamSettingsScreen.kt | 14 +- .../modules/settings/SettingsConstants.kt | 44 +- .../modules/settings/SettingsModels.kt | 6 +- .../modules/settings/SettingsSidebarLayout.kt | 3 +- .../components/InteractiveSettingCards.kt | 6 +- .../components/LocalMediaFoldersEditor.kt | 4 +- .../settings/sections/AppearanceSection.kt | 4 +- .../sections/LanguageRegionSection.kt | 15 +- .../modules/settings/sections/utils.kt | 12 +- .../spotube/modules/shell/AppBottombar.kt | 6 +- .../modules/shell/AppExpandedPlayer.kt | 84 +- .../spotube/modules/shell/AppLargePlayer.kt | 44 +- .../spotube/modules/shell/AppPlayerState.kt | 18 +- .../spotube/modules/shell/AppSidebar.kt | 14 +- .../modules/shell/PlayerHeartButton.kt | 8 +- .../modules/shell/PlayerOptionDialogs.kt | 140 ++- .../modules/shell/PlayerOptionsViewModel.kt | 19 +- .../AlternativeTrackContent.kt | 20 +- .../shell/player_queue/PlayerQueueContent.kt | 33 +- .../PlayerQueueContentViewModel.kt | 8 +- .../spotube/core/i18n/AppLocale.ios.kt | 41 + .../spotube/core/i18n/AppLocale.jvm.kt | 36 + .../spotube/core/systemtray/SystemTray.kt | 72 +- .../ui/component/ApplicationMainBar.jvm.kt | 14 +- .../kotlin/dev/krtirtho/spotube/main.kt | 13 +- .../core/i18n/AppLocaleProviderTest.kt | 91 ++ 101 files changed, 21763 insertions(+), 660 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.android.kt create mode 100644 composeApp/src/commonMain/composeResources/values-ar/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-bn/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-de/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-es/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-fa/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-fr/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-hi/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-id/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-it/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-ja/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-ko/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-nl/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-pl/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-pt/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-ru/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-th/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-tr/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-uk/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-vi/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values-zh/strings.xml create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.kt create mode 100644 composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.jvm.kt create mode 100644 composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/i18n/AppLocaleProviderTest.kt diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.android.kt new file mode 100644 index 00000000..0562a6fb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.android.kt @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.i18n + +import android.content.res.Configuration +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import java.util.Locale + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + // System resources are never touched by the override below, so they always report the + // device language (also after the user changes it while the app is running). + val locale = languageTag?.let(Locale::forLanguageTag) + ?: Resources.getSystem().configuration.locales[0] + // Compose resources read Locale.getDefault(); also used by non-UI code (notifications). + Locale.setDefault(locale) + + val configuration = Configuration(LocalConfiguration.current).apply { setLocale(locale) } + val resources = LocalContext.current.resources + @Suppress("DEPRECATION") + resources.updateConfiguration(configuration, resources.displayMetrics) + return LocalConfiguration provides configuration +} diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt index 9a0a19d8..060d6d50 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt @@ -41,6 +41,17 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.flow.first +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.library_toolbar_compact_title +import spotube.composeapp.generated.resources.media_browser_browse +import spotube.composeapp.generated.resources.media_browser_featured +import spotube.composeapp.generated.resources.media_browser_now_playing +import spotube.composeapp.generated.resources.media_browser_saved_tracks +import spotube.composeapp.generated.resources.media_browser_top_tracks class MediaBrowseHelper( private val pluginManager: PluginManager, @@ -221,13 +232,13 @@ class MediaBrowseHelper( } } - private fun buildRootChildren(): List { + private suspend fun buildRootChildren(): List { return listOf( MediaItem.Builder() .setMediaId(MEDIA_ID_BROWSE) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Browse") + .setTitle(getString(Res.string.media_browser_browse)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -237,7 +248,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_LIBRARY) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Library") + .setTitle(getString(Res.string.library_toolbar_compact_title)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -247,7 +258,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_QUEUE) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Now Playing") + .setTitle(getString(Res.string.media_browser_now_playing)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -264,7 +275,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_BROWSE_FEATURED) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Featured") + .setTitle(getString(Res.string.media_browser_featured)) .setIsBrowsable(true) .setIsPlayable(false) .setExtras(gridExtras) @@ -353,7 +364,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_PLAYLISTS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Playlists") + .setTitle(getString(Res.string.library_tab_playlists)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -363,7 +374,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_ALBUMS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Albums") + .setTitle(getString(Res.string.library_tab_albums)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -373,7 +384,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_ARTISTS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Artists") + .setTitle(getString(Res.string.library_tab_artists)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -383,7 +394,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_TRACKS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Saved Tracks") + .setTitle(getString(Res.string.media_browser_saved_tracks)) .setIsPlayable(false) .setIsBrowsable(true) .build() @@ -457,7 +468,7 @@ class MediaBrowseHelper( .setMediaId("$MEDIA_ID_ARTIST_TRACKS:$artistId") .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Top Tracks") + .setTitle(getString(Res.string.media_browser_top_tracks)) .setIsPlayable(false) .setIsBrowsable(true) .build() @@ -467,7 +478,7 @@ class MediaBrowseHelper( .setMediaId("$MEDIA_ID_ARTIST_ALBUMS:$artistId") .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Albums") + .setTitle(getString(Res.string.library_tab_albums)) .setIsBrowsable(true) .setIsPlayable(false) .build() diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt index 3c49b84c..c4177b0e 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt @@ -49,9 +49,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.notification_playback_channel_description +import spotube.composeapp.generated.resources.notification_playback_channel_name +import spotube.composeapp.generated.resources.notification_playback_service_text @OptIn(UnstableApi::class) class PlaybackService : MediaLibraryService(), KoinComponent { @@ -80,7 +86,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Spotube") - .setContentText("Playback service") + .setContentText(runBlocking { getString(Res.string.notification_playback_service_text) }) .setSmallIcon(android.R.drawable.ic_media_play) .setContentIntent(sessionActivity) .setOngoing(true) @@ -112,10 +118,12 @@ class PlaybackService : MediaLibraryService(), KoinComponent { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, - "Playback", + runBlocking { getString(Res.string.notification_playback_channel_name) }, NotificationManager.IMPORTANCE_LOW ).apply { - description = "Media playback controls" + description = runBlocking { + getString(Res.string.notification_playback_channel_description) + } setShowBadge(false) } val manager = getSystemService(NotificationManager::class.java) diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml new file mode 100644 index 00000000..674b83a9 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -0,0 +1,962 @@ + + + + قائمة الانتظار + مسح قائمة الانتظار + تصفية قائمة الانتظار... + لا توجد عناصر في قائمة الانتظار + + ابحث عن أغانٍ وفنانين وألبومات... + مسح السجل بالكامل + بحث... + + + مكتبتك + المكتبة + قوائم التشغيل + الألبومات + الفنانون + المقاطع المحلية + التنزيلات + ابحث في قوائم التشغيل المحفوظة… + ابحث في الألبومات المحفوظة… + ابحث في الفنانين المحفوظين… + ابحث في المجلدات أو المقاطع… + تصفية المكتبة + إغلاق التصفية + إنشاء قائمة تشغيل + تحديث المقاطع المحلية + التبديل إلى عرض الشبكة + التبديل إلى عرض القائمة + لا توجد قوائم تشغيل محفوظة + لا توجد قوائم تشغيل تطابق “%1$s” + لا توجد ألبومات محفوظة + لا توجد ألبومات تطابق “%1$s” + لا يوجد فنانون محفوظون + لا يوجد فنانون يطابقون “%1$s” + + + المجلدات + مجلد + %1$s • %2$s + + %1$d مقطع + مقطع واحد + %1$d مقطعان + %1$d مقاطع + %1$d مقطعًا + %1$d مقطع + + + %1$d مجلد + مجلد واحد + %1$d مجلدان + %1$d مجلدات + %1$d مجلدًا + %1$d مجلد + + العودة إلى المجلدات + تشغيل المجلد + يلزم الوصول إلى الصوت + اسمح بالوصول إلى الصوت لتصفح الموسيقى على جهازك. + منح الإذن + لم يتم العثور على مجلدات محلية + لا توجد مقاطع في هذا المجلد + لا توجد مجلدات أو مقاطع مطابقة + تعذّر تحديث الوسائط المحلية + + + %1$d نشط • %2$d مكتمل + مسح المكتملة + لا توجد تنزيلات بعد + جارٍ التنزيل %1$d% + في قائمة الانتظار + مكتمل + ملغى + فشل + فشل: %1$s + إلغاء التنزيل + إعادة محاولة التنزيل + إزالة التنزيل + + + فنان + محظور + المقاطع التي أعجبتك + أغانيك المحفوظة + فنان غير معروف + عنوان المقطع + اسم الفنان + + محدد + + الإعدادات + + الإضافات + اللغة والمنطقة + المظهر + التشغيل + التنزيلات + التخزين المؤقت + سطح المكتب + التحديثات + الدعم + حول + + إغلاق + حفظ + إلغاء + + اللغة + لغة واجهة التطبيق. الحالية: %1$s + افتراضي النظام + البلد + البلد المستخدم للمحتوى والتوفر. الحالي: %1$s + %1$s (%2$s) + + السمة + اختر كيف يتوافق Spotube مع مظهر نظامك. الحالية: %1$s + فاتح + داكن + النظام + + السطح + مادة خلفية التطبيق. الحالية: %1$s + مائي + جليدي + كروم + أكريليك + زجاج + + لون التمييز + لوحة الألوان الحالية: %1$s + لون التمييز + اختر لوحة ألوان تمييز واعرض معاينتها في السمتين الفاتحة والداكنة. + فاتح + داكن + معاينة + زر + أخضر غوبلن + بنفسجي كهربائي + سماوي محيطي + برتقالي الغروب + حديقة الورد + أزرق منتصف الليل + أردوازي معدني + + الشكل والمظهر + مادة السطح ولون التمييز. الحالي: %1$s · %2$s + الشكل والمظهر + اختر مادة السطح، ثم لون تمييز يتناسب معها. كل خيار هو تركيبة منتقاة بعناية. + السطح + لون التمييز + ثانوي + + لوحة الألوان + لوحة ألوان تمييز Millennium. الحالية: %1$s + لوحة الألوان + اختر لوحة ألوان تمييز Millennium واعرض معاينتها في الوضعين الفاتح والداكن. + لوحة الألوان + لوحة ألوان Millennium ولون التمييز. الحالية: %1$s + أزرق + برتقالي + ليموني + + تخصيص مجلد التنزيل + المجلد الحالي: %1$s + المجلد الحالي: مجلد الموسيقى الافتراضي + اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي. + C:\Music\Spotube + مجلدات الوسائط المحلية + مجلدات مخصصة للفحص على سطح المكتب. المضافة: %1$d + لم تتم إضافة أي مجلد مخصص بعد + مجلدات مخصصة لمكتبة موسيقاك المحلية. + إدارة + إضافة مجلد + إزالة المجلد + لم تتم إضافة مجلدات مخصصة. + إزالة %1$s من قائمة الفحص + يتم تضمين مجلد التنزيل ومجلد ذاكرة التخزين المؤقت للموسيقى تلقائيًا. + يتم تضمين مجلد التنزيل تلقائيًا. + يتم تضمين مجلد ذاكرة التخزين المؤقت للموسيقى تلقائيًا. + جارٍ الحفظ… + تعذّر حفظ المجلدات. لا تزال تغييراتك موجودة؛ حاول مرة أخرى. + + %1$d مجلد + مجلد واحد + %1$d مجلدان + %1$d مجلدات + %1$d مجلدًا + %1$d مجلد + + صيغة تنزيل الموسيقى + الترميز والحاوية المفضلان للتنزيلات. الحالي: %1$s + جودة تنزيل الموسيقى + + صيغة بث الموسيقى + الترميز والحاوية المفضلان لبث الموسيقى. الحالي: %1$s + جودة بث الموسيقى + الحالي: %1$s + تفعيل التخزين المؤقت للموسيقى + خزّن الصوت الذي يتم بثه محليًا لتسريع إعادة التشغيل. + مجلد ذاكرة التخزين المؤقت + الحالي: %1$s + الحالي: ذاكرة التخزين المؤقت الافتراضية للتطبيق + الحد الأقصى لحجم ذاكرة التخزين المؤقت + بلا حد + الحد الحالي: %1$d MB + حدد الحجم الأقصى لذاكرة التخزين المؤقت بوحدة MB. أدخل 0 أو اتركه فارغًا لعدم وضع حد. + 500 + يجب أن يكون الحجم 0 أو رقمًا موجبًا. + تفعيل التشغيل اللانهائي + واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار. + تفعيل Connect + إتاحة عناصر التحكم في التشغيل عن بُعد عبر ميزة Spotube Connect. + منفذ خادم وكيل التشغيل + السماح بالتحكم عن بُعد + اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها. + اسم الجهاز للتحكم عن بُعد + الاسم الذي تراه الأجهزة الأخرى عند اكتشاف هذا الجهاز. الحالي: %1$s + اختر اسمًا مألوفًا يظهر للأجهزة الأخرى على شبكتك عند اكتشافها لنسخة Spotube هذه. + مثال: Spotube غرفة المعيشة + اسم مضيف الجهاز + المنفذ الذي يستخدمه خادم وكيل التشغيل. الحالي: %1$d + اختر منفذًا بين 1 و65535. + 14769 + أدخل عددًا صحيحًا صالحًا. + يجب أن يكون المنفذ بين 1 و65535. + + قائمة الحظر + إدارة المقاطع والفنانين المحظورين + + التصغير إلى شريط النظام + إبقاء Spotube قيد التشغيل في شريط النظام عند تصغير النافذة. + حالة Discord التفاعلية + اعرض حالة التشغيل الحالية في Discord على سطح المكتب. + + التحقق من التحديثات تلقائيًا + البحث عن إصدارات Spotube الجديدة في الخلفية. + + إدارة الإضافات + أدِر إضافاتك + إضافة %1$s الافتراضية + البيانات الوصفية + الصوت + كلمات الأغاني + تسجيل الاستماع + لم يتم تحديد إضافة + تغيير + تحديد + محددة + مسح التحديد + لا تتوفر إضافات + إضافة %1$s + مسح + + ضبط + مدير الإضافات + يُرجى إدخال URL + يجب أن يبدأ URL بـ http:// أو https:// + تعذّر تنزيل الإضافة + لم يتم تثبيت أي إضافات بعد + الصق رابط تنزيل أو اختر ملفًا أعلاه. + + %1$d إضافة مثبتة + إضافة واحدة مثبتة + %1$d إضافتان مثبتتان + %1$d إضافات مثبتة + %1$d إضافة مثبتة + %1$d إضافة مثبتة + + تثبيت إضافة + https://example.com/plugin.smplug + تنزيل + التثبيت من ملف (.smplug) + نشطة + v%1$s + مدمجة + إزالة الإضافة + تسجيل الدخول + تسجيل الخروج + التنزيل من URL + التثبيت من ملف + تثبيت + + • %1$s + الأذونات المطلوبة + لا تتطلب هذه الإضافة أي أذونات خاصة. + المثبتة مقابل المقدَّمة + المثبتة + المقدَّمة + API %1$s → %2$s + التخزين الدائم + قراءة البيانات وكتابتها على القرص + طلبات الشبكة + إرسال البيانات واستقبالها عبر الإنترنت + WebView + عرض محتوى الويب داخل التطبيق + الجام الجماعي + وسيط MQTT + مضيف الوسيط + %1$s:%2$d + منفذ الوسيط + استخدام TLS + اسم المستخدم (اختياري) + كلمة المرور (اختياري) + بادئة معرّف العميل + تُستخدم لتعريف نسخة Spotube هذه + تشفير الاتصال بالوسيط + تحقق من إعدادات الوسيط الحالية قبل بدء غرفة. + اختبار الاتصال + جارٍ الاختبار… + تم الاتصال خلال %1$d ms + فشل: %1$s + وسيط مؤقت — اضبط وسيطك الخاص للاستضافة الذاتية + الاتصال + بيانات الاعتماد + الهوية + التشخيص + + + الجام الجماعي + إنشاء + انضمام + أنشئ غرفة، ثم شارك رمزها مع أصدقائك. + إنشاء غرفة + الانضمام إلى غرفة + جارٍ الاتصال… + رمز الغرفة + ABC123 + الوسيط: %1$s + اضبط وسيطًا في الإعدادات لاستضافة غرفة أو الانضمام إليها. + إعدادات الوسيط + متصل + جارٍ إعادة الاتصال… + نسخ الرمز + تم النسخ! + مشاركة + المشاركون (%1$d) + المضيف + مستمع + في انتظار المستمعين… + إزالة + حظر + إدارة + مغادرة الغرفة + تعذّر الاتصال + تجاهل + تعذّر إنشاء جلسة الجام + تعذّر الانضمام إلى جلسة الجام + أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام. + انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s + + الانتقال إلى الألبوم + مؤقت النوم + تفاصيل المقطع + تشغيل + إيقاف مؤقت + لا شيء قيد التشغيل + إضافة إلى قائمة الانتظار + + تجاهل + تحديث + يتوفر Spotube %1$s + اطّلع على الجديد في هذا الإصدار في صفحة تنزيلات Spotube. + يتوفر %1$s %2$s + المثبت حاليًا: %1$s + اطّلع على الجديد في هذا التحديث في صفحة الإضافة. + + تخطٍّ + التالي + رجوع + البدء + Spotube + موسيقى بلا قيود. + مشغّل مفتوح المصدر لعالم من الموسيقى.\nلك أن تستكشفه. ولك أن تجعله على طريقتك. + ساعد في استمرار هذا المشروع. + يبني Spotube أناسٌ حقيقيون، لا شركة اشتراكات. إن أصبح جزءًا من حياتك اليومية، فإن القليل من الدعم يساعدنا على منحه الرعاية التي يستحقها.\n\nشكرًا لإفساحك المجال للبرمجيات المستقلة. + ابدأ بإضافة كتالوج للعثور على الأغاني وقوائم التشغيل. اختر إضافة صوت لتشغيلها. كلمات الأغاني وسجل الاستماع اختياريان. اكتشف إضافة أدناه، وراجع أذوناتها، وثبّتها، ثم حدّدها هنا. + اكتشاف الإضافات + مصادرك + إعادة المحاولة + المزيد من الإضافات + لم يتم العثور على إضافات جديدة. يمكنك أيضًا تثبيت إضافة من رابط أو من ملف .smplug أعلاه. + اكتشاف الإضافات غير متاح حاليًا. حاول مرة أخرى، أو ثبّت من رابط أو ملف. + مرحبًا + الدعم + الإضافات + الدليل + + كيفية استخدام Spotube + أربع خطوات سريعة وتبدأ الاستماع. + تسجيل الدخول + اربط حسابك على Spotify، أو اختر إضافة الكتالوج الخاصة بك. + ابحث وشغّل + اعثر على أي أغنية أو ألبوم أو فنان، ثم اضغط تشغيل. + استمع بلا إنترنت + نزّل المقاطع لمواصلة التشغيل دون اتصال. + تابع كلمات الأغاني + افتح كلمات الأغاني المتزامنة من المشغّل أثناء تشغيل المقطع. + + + موسيقاك. مصادرك. قواعدك. + مشغّل موسيقى مجاني ومفتوح المصدر، بناه مجتمع يحب الموسيقى ببساطة. + مفتوح المصدر + بلا إعلانات + بلا قياس عن بُعد + مدعوم بالإضافات + هيا بنا + + + الداعمون يبقوننا مستمرين + صُنع بشغف. ويستمر بفضلك. + Spotube مجاني 100%، وحر، ومفتوح المصدر — بلا إعلانات، وبلا اشتراكات، وبلا تتبع. كل ميزة موجودة لأن أشخاصًا مثلك اختاروا المساعدة في استمرار تدفق الشيفرة. + Patreon + راعٍ شهري + ادعم التطوير والبنية التحتية بشكل مستمر + Buy Me a Coffee + قهوة لمرة واحدة + موّل جلسة برمجة ليلية بإكرامية سريعة + Open Collective + صندوق المجتمع + دعم مالي شفاف يديره المجتمع + نشكرك من أعماق قلوبنا لإيمانك بالموسيقى المفتوحة. + الدعم اختياري تمامًا — يبقى Spotube مجانيًا في كل الأحوال. + ادعم + فتح %1$s + + + ادعم Spotube + ادعم Spotube + Spotube مجاني وحر ومفتوح المصدر — بلا إعلانات، وبلا اشتراكات، وبلا تتبع. إن كان يُسعدك، ففكّر في دعم المشروع. + يمكنك إيقاف هذه التذكيرات من الإعدادات ← الدعم. + ربما لاحقًا + تذكيرات الدعم + عرض تذكير لطيف لدعم المشروع كل 7 أيام. + + + بنية معيارية + اختر مصدر موسيقاك. + يفصل Spotube بين اكتشاف الكتالوج وبث الصوت وكلمات الأغاني المتزامنة. اضبط محركاتك أدناه أو بدّلها في أي وقت. + محرك البيانات الوصفية + يشغّل اكتشاف الموسيقى والبحث وقوائم المقاطع وقوائم التشغيل. + محرك الصوت + يجلب تدفقات الصوت التي يتم تشغيلها ويفك ترميزها. + محرك كلمات الأغاني + يعرض كلمات كاريوكي متزامنة زمنيًا في الوقت الفعلي. + محرك تسجيل الاستماع + يسجّل سجل الاستماع في Last.fm أو ListenBrainz. + نشط + مدمج + جاهز + اختياري + اختر محركًا + المحركات المثبتة + تعيين كافتراضي + NewPipe YouTube وLRCLib Lyrics مدمجان وجاهزان للبث فورًا. + أدِر إضافات المجتمع أو أضف إضافات جديدة في أي وقت من الإعدادات ← الإضافات. + + + بنية عصرية بروح كلاسيكية + صوت Hi-Fi نقي، بلا وسطاء + إليك كيف ينسّق Spotube التشغيل اللامركزي مباشرةً على جهازك. + اتصال مباشر من نظير إلى نظير • 256 kbps Opus + الساعة الزرقاء + Spotube · عرض توضيحي للمشغّل + قليل من الموسيقى للحظات العابرة. + 1. اكتشاف لامركزي + تذهب الاستعلامات مباشرةً إلى إضافة الكتالوج التي اخترتها. لا ينشئ Spotube أبدًا ملفًا تعريفيًا لذوقك. + 2. تحديد البث المباشر + يُبث الصوت مباشرةً من خوادم المحتوى إلى جهازك. بلا قفزات عبر وكلاء ولا جمع للبيانات عن بُعد. + 3. معالجة صوتية وتشغيل محليان + يتم فك الترميز والتخزين المؤقت محليًا مع عناصر تحكم في المعادل وخيار التنزيل للاستماع دون اتصال. + 4. كلمات متزامنة وجام + تتبع كلمات الكاريوكي توقيت التشغيل، مع تحكم عن بُعد عبر LAN والجام الجماعي. + لا خوادم وسيطة. يتم التشغيل على جهازك مباشرةً — الخصوصية في صميم التصميم. + + + اختر جهازًا + %1$s: «%2$s» + تشغيل + إضافة إلى قائمة الانتظار + تشغيل التالي + هذا الجهاز + على هذا الجهاز + جهاز بعيد + جهاز بعيد (%1$s) + على الجهاز المتصل + + + فشل الاتصال + انقطع الاتصال + فشل الإرسال + فشل الاكتشاف: %1$s + تعذّر الاتصال: %1$s + + + الأجهزة + تحديث + متصل بـ %1$s:%2$d + قطع الاتصال + جارٍ الاتصال... + جارٍ البحث عن أجهزة على الشبكة... + لم يتم العثور على أجهزة + تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر. + جهاز غير معروف + جارٍ التحديد... + المعرّف: %1$s... + + + التحكم عن بُعد + غير متصل + خطأ في الاتصال: %1$s + غلاف الألبوم + مقطع غير معروف + فنان غير معروف + التقديم والترجيع + ترتيب عشوائي + السابق + التالي + وضع التكرار + مستوى الصوت + إزالة من قائمة الانتظار + خيارات إضافية + + + أين تريد التشغيل؟ + لديك جلسة جام نشطة. اختر أين تريد تشغيل هذه المجموعة. + التشغيل هنا + اقتراح للجام + + + لم يتم ضبط وسيط للجام + مضيف الوسيط فارغ + فشل الاتصال + انقطع الاتصال + + + الوسيط + مجهول + + + تمت الإضافة إلى قائمة انتظار الجام + + تمت إضافة %1$d مقطع إلى قائمة انتظار الجام + تمت إضافة مقطع واحد إلى قائمة انتظار الجام + تمت إضافة %1$d مقطعين إلى قائمة انتظار الجام + تمت إضافة %1$d مقاطع إلى قائمة انتظار الجام + تمت إضافة %1$d مقطعًا إلى قائمة انتظار الجام + تمت إضافة %1$d مقطع إلى قائمة انتظار الجام + + + + طلب تحكم عن بُعد + يريد \"%1$s\" التحكم في التشغيل على هذا الجهاز. + رفض + سماح + السماح دائمًا + + + نتائج البحث + الكل + المقاطع + المستخدمون + لا تتوفر أنواع بحث + ابدأ الكتابة للبحث + ابدأ الكتابة للبحث عن مقاطع + لم يتم العثور على نتائج + عرض الكل + جارٍ البحث + جارٍ تحميل المقاطع... + جارٍ تحميل الألبومات... + جارٍ تحميل الفنانين... + جارٍ تحميل قوائم التشغيل... + جارٍ تحميل المستخدمين... + لم يتم العثور على ألبومات + لم يتم العثور على فنانين + لم يتم العثور على قوائم تشغيل + لم يتم العثور على مستخدمين + نبحث بين صناديق الأسطوانات... + نبحث في أرجاء الكون... + نُحمّي مكبرات الصوت... + ننفض الغبار عن الأسطوانات... + نستشير آلهة الموسيقى... + نضبط الهوائيات... + نقلّب بين التسجيلات... + تعذّر تحميل إمكانات البحث + تعذّر البحث + تعذّر البحث عن المقاطع + تعذّر البحث عن الألبومات + تعذّر البحث عن الفنانين + تعذّر البحث عن قوائم التشغيل + تعذّر البحث عن المستخدمين + + + تصفّح + + + متزامنة + عادية + لا تتوفر كلمات متزامنة + لا تتوفر كلمات عادية + + + %1$s في %2$s + %1$d kbps + %1$s kHz • %2$d قناة + لم يتم تحديد إضافة بيانات وصفية + + + رجوع + إعادة المحاولة + إغلاق مربع الحوار + جارٍ التحميل... + غير معروف + خطأ غير معروف + + + مستخدم + قائمة تشغيل + + + نعتذر بشدة + هناك خلل ما في الوقت الحالي. تجربتك تهمنا، ونبذل قصارى جهدنا لإصلاح ذلك. يُرجى المحاولة مرة أخرى أو مراجعة تفاصيل الخطأ أدناه. + تفاصيل الخطأ + نسخ إلى الحافظة + عرض السجل الكامل + سجل الخطأ الكامل + + + ترتيب عشوائي + تشغيل عشوائي + متابعة + إلغاء المتابعة + تعديل قائمة التشغيل + بواسطة %1$s + + + خيارات المقطع + إضافة إلى الجام + بدء الراديو + تشغيل التالي + نقل إلى التالي + إزالة من قائمة الانتظار + إزالة من المفضلة + حفظ في المفضلة + إضافة إلى قائمة تشغيل + تنزيل + مشاركة + + + الأصلي + العنوان + الفنان + الألبوم + المدة + ترتيب + لم يتم العثور على مقاطع + تصفية + إجراءات جماعية + تنزيل الكل + تنزيل %1$d + إضافة الكل إلى قائمة الانتظار + إضافة %1$d إلى قائمة الانتظار + تشغيل الكل تاليًا + تشغيل %1$d تاليًا + إضافة الكل إلى قائمة تشغيل + إضافة %1$d إلى قائمة تشغيل + إضافة الكل إلى الجام + إضافة %1$d إلى الجام + +%1$d آخرين + + + لا يوجد URL لتحميله + الرجوع في سجل المتصفح + التقدم في سجل المتصفح + + + تصفية قائمة الحظر... + لا توجد عناصر محظورة + لا توجد عناصر محظورة تطابق \'%1$s\' + المقاطع + مقطع + إضافة إلى قائمة الحظر + إزالة من قائمة الحظر + + + لا توجد بيانات وصفية للمقطع + تعذّر تحديد URL البث + تعذّر تحديد URL البث. جرّب تشغيل المقطع أولًا. + + + المقاطع المحفوظة + أنت + + + تتابعه + أفضل المقاطع + تشغيل أفضل المقاطع + إضافة أفضل المقاطع إلى قائمة الانتظار + فنانون مشابهون + قوائم تشغيل مميزة + تعذّر تحميل نظرة عامة على الفنان + عدد المتابعين غير متاح + %1$s ألف متابع + %1$s مليون متابع + %1$s مليار متابع + + %1$d متابع + متابع واحد + %1$d متابعان + %1$d متابعين + %1$d متابعًا + %1$d متابع + + + + إعجاب + إلغاء الإعجاب + عنوان مقطع افتراضي + فنانون افتراضيون + ألبوم غير معروف + التقديم والترجيع + مستوى الصوت + تفعيل الترتيب العشوائي + إيقاف الترتيب العشوائي + السابق + التالي + تشغيل أو إيقاف مؤقت + التكرار متوقف + تكرار مقطع واحد + تكرار الكل + كتم الصوت + إلغاء كتم الصوت + خيارات إضافية + خيارات المشغّل + طي المشغّل + توسيع كلمات الأغنية + قيد التشغيل الآن + كلمات الأغنية + مصدر بديل + مصدر بديل + تنزيل + تم التنزيل + جارٍ التنزيل... + تم التنزيل مسبقًا + جارٍ إعادة محاولة التنزيل... + جارٍ تنزيل %1$s + سيتوقف التشغيل مؤقتًا بعد %1$s. + إيقاف التشغيل مؤقتًا بعد عدد الدقائق المحدد. + الدقائق + اختر بين 1 و600 دقيقة + إلغاء المؤقت + بدء + تم تحديد URL التشغيل بإعدادات بث مختلفة. يطابق البث المميز تفضيلك الحالي؛ وسيتم تحديث URL النشط عند طلب البث التالي. + مصدر الصوت المطابق + جارٍ تحميل المصدر المطابق… + لا حاجة إلى مصدر بعيد مطابق أثناء تشغيل الملف المخزن مؤقتًا محليًا. + لا يتوفر مصدر مطابق. + لا يوجد مصدر صوت مطابق مخزن مؤقتًا لهذا المقطع. + لم يتم تخزين تفاصيل البث مؤقتًا بعد. شغّل المقطع أولًا لتحميلها. + تعذّر تحميل تفاصيل المقطع. + التدفقات المتاحة + يتم تقديم هذا المقطع من ذاكرة التخزين المؤقت المحلية. لا تُستخدم متغيرات البث لهذا التشغيل. + جارٍ تحميل التدفقات… + لا تتوفر تدفقات لهذا المصدر. + البث المحدد حاليًا + يطابق تفضيلات صيغة البث وجودته + URL التشغيل النشط + مُقدَّم من ذاكرة التخزين المؤقت المحلية + مقطع البيانات الوصفية + العنوان + المعرّف + الألبوم + درجة الثقة + URI خارجي + معرّف الألبوم + نوع الألبوم + تاريخ إصدار الألبوم + عدد مقاطع الألبوم + أنواع الألبوم الموسيقية + المدة + رقم المقطع + رقم القرص + محتوى صريح + الشعبية + ISRC + عناوين URL للصور المصغرة + + + إظهار/إخفاء الشريط الجانبي + + + مصادر بديلة + لا يوجد مقطع قيد التشغيل حاليًا + جارٍ البحث عن مصادر بديلة... + لم يتم العثور على مصادر بديلة + درجة الثقة: %1$d% + المصدر النشط + + + طرد + إزالته من الجلسة + طرده ومنعه من الانضمام مجددًا + إزالة الاقتراحات + إزالة كل المقاطع التي أضافها إلى قائمة الانتظار + إعادة الترتيب + إزالة من قائمة الانتظار + %1$s • أضافه %2$s + + + غير معروف + جارٍ تحميل قائمة التشغيل... + إضافة مقاطع + إضافة مقاطع إلى قائمة التشغيل + ابحث عن مقاطع... + بحث + لم يتم العثور على مقاطع + ابحث عن مقاطع لإضافتها + تم + إضافة + تمت الإضافة + + + جارٍ تحميل الألبوم... + + + إضافة إلى قائمة تشغيل خاصة بك + بحث + ابحث في قوائم التشغيل الخاصة بك... + جارٍ تحميل قوائم التشغيل الخاصة بك... + لا تملك أي قوائم تشغيل بعد.\nأنشئ واحدة لإضافة المقاطع. + إنشاء قائمة تشغيل + تعديل قائمة التشغيل + إنشاء + الاسم + اسم قائمة التشغيل + الوصف + الوصف (اختياري) + عامة + يمكن للجميع رؤية قائمة التشغيل هذه + تعاونية + يمكن للآخرين إضافة مقاطع إلى قائمة التشغيل هذه + صورة الغلاف + غلاف قائمة التشغيل + لا توجد صورة + تغيير الصورة + اختيار صورة + + + تثبيت + استبدال + لم يتم العثور على plugin.json في ملف zip + لم يتم العثور على manifest.zipline.json في ملف zip + تنسيق plugin.json غير صالح: %1$s + تعذّرت قراءة الإضافة: %1$s + تعذّر تثبيت الإضافة: %1$s + يتوفر إصدار أحدث من %1$s. أكّد التحديث قبل التثبيت. + %1$s مثبتة بالفعل. أكّد استبدالها قبل التثبيت. + الإضافات المدمجة مضمّنة بالفعل ولا يمكن إضافتها مرة أخرى. + لا يمكن إزالة الإضافات المدمجة. + تعلن %1$s عن قيمة apiVersion غير صالحة (%2$s). المتوقع إصدار دلالي متوافق مع %3$s. + إصدار API الإضافات في Spotube %1$s غير صالح. + تستهدف %1$s إصدار API الإضافات %2$s، لكن Spotube يدعم إصدار API الإضافات الرئيسي %3$s. ثبّت إضافة مبنية لنفس الإصدار الرئيسي من API. + + + API الإضافة غير متوافق + تثبيت الإضافة؟ + ستتم إضافة %1$s إلى إضافاتك المثبتة. + تحديث الإضافة؟ + إصدار أقدم مثبت (%1$s). هل تريد التحديث إلى %2$s؟ + استبدال الإضافة المثبتة؟ + تستخدم %1$s إصدارًا دلاليًا غير صالح (%2$s) ولا يمكن مقارنتها كتحديث. هل تريد استبدال الإضافة المثبتة على أي حال؟ + لا يمكن مقارنة الإصدار المثبت (%1$s) باستخدام الإصدار الدلالي. هل تريد استبداله بـ %2$s؟ + الإصدار %1$s مثبت بالفعل. هل تريد استبدال الإضافة الحالية بالنسخة المقدَّمة؟ + الإصدار المثبت %1$s أحدث من الإصدار المقدَّم %2$s. هل تريد استبداله على أي حال؟ + هل تريد استبدال الإضافة المثبتة بالنسخة المقدَّمة؟ + + + %1$s: + الإصدار + إصدار API + المؤلف + الترخيص + الإمكانات + القدرات + المستودع + التواصل + الإبلاغ عن الأخطاء + ادعم %1$s + + + رسمية + موثّقة + نجوم GitHub + URL مستودع GitHub + + + لم يتم العثور على إصدارات + اختيار الإصدار + اختر إصدارًا + إصدار تجريبي + ملاحظات الإصدار + + + الرئيسية + بحث + على الجهاز + الاتصال + الأجهزة + + + إخفاء النافذة + إظهار النافذة + المقطع التالي + المقطع السابق + ترتيب عشوائي + تكرار + التكرار: متوقف + التكرار: مقطع واحد + التكرار: الكل + مستوى الصوت: %1$d% + رفع الصوت + + خفض الصوت - + كتم الصوت + إلغاء كتم الصوت + الإعجاب بالمقطع + إلغاء الإعجاب بالمقطع + خروج + + + تصغير + تكبير + استعادة + إغلاق + + + خدمة التشغيل + التشغيل + عناصر التحكم في تشغيل الوسائط + + + تصفّح + قيد التشغيل الآن + مميز + المقاطع المحفوظة + أفضل المقاطع + diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml new file mode 100644 index 00000000..03b55163 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -0,0 +1,938 @@ + + + + কিউ + কিউ খালি করুন + কিউ ফিল্টার করুন... + কিউতে কিছু নেই + + গান, শিল্পী, অ্যালবাম খুঁজুন... + সব হিস্ট্রি মুছুন + খুঁজুন... + + + আপনার লাইব্রেরি + লাইব্রেরি + প্লেলিস্ট + অ্যালবাম + শিল্পী + লোকাল ট্র্যাক + ডাউনলোড + সেভ করা প্লেলিস্ট খুঁজুন… + সেভ করা অ্যালবাম খুঁজুন… + সেভ করা শিল্পী খুঁজুন… + ফোল্ডার বা ট্র্যাক খুঁজুন… + লাইব্রেরি ফিল্টার করুন + ফিল্টার বন্ধ করুন + প্লেলিস্ট তৈরি করুন + লোকাল ট্র্যাক রিফ্রেশ করুন + গ্রিড ভিউতে যান + লিস্ট ভিউতে যান + কোনো সেভ করা প্লেলিস্ট নেই + “%1$s”-এর সাথে মেলে এমন কোনো প্লেলিস্ট নেই + কোনো সেভ করা অ্যালবাম নেই + “%1$s”-এর সাথে মেলে এমন কোনো অ্যালবাম নেই + কোনো সেভ করা শিল্পী নেই + “%1$s”-এর সাথে মেলে এমন কোনো শিল্পী নেই + + + ফোল্ডার + ফোল্ডার + %1$s • %2$s + + %1$dটি ট্র্যাক + %1$dটি ট্র্যাক + + + %1$dটি ফোল্ডার + %1$dটি ফোল্ডার + + ফোল্ডারে ফিরে যান + ফোল্ডার প্লে করুন + অডিও অ্যাক্সেস প্রয়োজন + আপনার ডিভাইসের গান দেখতে অডিও অ্যাক্সেসের অনুমতি দিন। + অনুমতি দিন + কোনো লোকাল ফোল্ডার পাওয়া যায়নি + এই ফোল্ডারে কোনো ট্র্যাক নেই + মেলে এমন কোনো ফোল্ডার বা ট্র্যাক নেই + লোকাল মিডিয়া রিফ্রেশ করা যায়নি + + + %1$dটি চলমান • %2$dটি সম্পন্ন + সম্পন্নগুলো মুছুন + এখনো কোনো ডাউনলোড নেই + ডাউনলোড হচ্ছে %1$d% + অপেক্ষমাণ + সম্পন্ন + বাতিল + ব্যর্থ + ব্যর্থ: %1$s + ডাউনলোড বাতিল করুন + আবার ডাউনলোড করুন + ডাউনলোড সরান + + + শিল্পী + ব্ল্যাকলিস্টেড + পছন্দের ট্র্যাক + আপনার সেভ করা গান + অজানা শিল্পী + ট্র্যাকের নাম + শিল্পীর নাম + + নির্বাচিত + + সেটিংস + + প্লাগইন + ভাষা ও অঞ্চল + অ্যাপিয়ারেন্স + প্লেব্যাক + ডাউনলোড + ক্যাশিং + ডেস্কটপ + আপডেট + সাপোর্ট + সম্পর্কে + + বন্ধ করুন + সেভ করুন + বাতিল + + ভাষা + অ্যাপ ইন্টারফেসের ভাষা। বর্তমান: %1$s + সিস্টেম ডিফল্ট + দেশ + কনটেন্ট ও প্রাপ্যতার জন্য দেশ। বর্তমান: %1$s + %1$s (%2$s) + + থিম + Spotube আপনার সিস্টেমের অ্যাপিয়ারেন্সের সাথে কীভাবে মিলবে তা বেছে নিন। বর্তমান: %1$s + লাইট + ডার্ক + সিস্টেম + + সারফেস + অ্যাপের ব্যাকগ্রাউন্ড ম্যাটেরিয়াল। বর্তমান: %1$s + জলীয় + বরফি + ক্রোম + অ্যাক্রিলিক + কাচ + + অ্যাকসেন্ট রং + বর্তমান প্যালেট: %1$s + অ্যাকসেন্ট রং + একটি অ্যাকসেন্ট প্যালেট বেছে নিন এবং লাইট ও ডার্ক দুই থিমেই প্রিভিউ দেখুন। + লাইট + ডার্ক + প্রিভিউ + বাটন + গ্রিন গবলিন + ইলেকট্রিক ভায়োলেট + ওশেনিক সায়ান + সানসেট অরেঞ্জ + রোজ গার্ডেন + মিডনাইট ব্লু + মেটালিক স্লেট + + লুক ও ফিল + সারফেস ম্যাটেরিয়াল ও অ্যাকসেন্ট। বর্তমান: %1$s · %2$s + লুক ও ফিল + একটি সারফেস ম্যাটেরিয়াল বেছে নিন, তারপর তার সাথে মানানসই একটি অ্যাকসেন্ট। প্রতিটি অপশনই বাছাই করা কম্বিনেশন। + সারফেস + অ্যাকসেন্ট + সেকেন্ডারি + + প্যালেট + Millennium অ্যাকসেন্ট প্যালেট। বর্তমান: %1$s + প্যালেট + একটি Millennium অ্যাকসেন্ট প্যালেট বেছে নিন এবং লাইট ও ডার্কে প্রিভিউ দেখুন। + প্যালেট + Millennium প্যালেট ও অ্যাকসেন্ট। বর্তমান: %1$s + নীল + কমলা + লাইম + + ডাউনলোড ফোল্ডার পরিবর্তন + বর্তমান ফোল্ডার: %1$s + বর্তমান ফোল্ডার: ডিফল্ট মিউজিক ফোল্ডার + ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন। + C:\Music\Spotube + লোকাল মিডিয়া ফোল্ডার + ডেস্কটপে স্ক্যান করার জন্য কাস্টম ফোল্ডার। যোগ করা হয়েছে: %1$d + এখনো কোনো কাস্টম ফোল্ডার যোগ করা হয়নি + আপনার লোকাল মিউজিক লাইব্রেরির জন্য কাস্টম ফোল্ডার। + ম্যানেজ করুন + ফোল্ডার যোগ করুন + ফোল্ডার সরান + কোনো কাস্টম ফোল্ডার যোগ করা হয়নি। + স্ক্যান তালিকা থেকে %1$s সরান + ডাউনলোড ও মিউজিক ক্যাশ ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + আপনার ডাউনলোড ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + মিউজিক ক্যাশ ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + সেভ হচ্ছে… + ফোল্ডার সেভ করা যায়নি। আপনার পরিবর্তনগুলো রয়ে গেছে; আবার চেষ্টা করুন। + + %1$dটি ফোল্ডার + %1$dটি ফোল্ডার + + ডাউনলোডের মিউজিক ফরম্যাট + ডাউনলোডের জন্য পছন্দের কোডেক ও কন্টেইনার। বর্তমান: %1$s + ডাউনলোডের মিউজিক কোয়ালিটি + + স্ট্রিমিংয়ের মিউজিক ফরম্যাট + মিউজিক স্ট্রিমিংয়ের জন্য পছন্দের কোডেক ও কন্টেইনার। বর্তমান: %1$s + স্ট্রিমিংয়ের মিউজিক কোয়ালিটি + বর্তমান: %1$s + মিউজিক ক্যাশিং চালু করুন + বারবার প্লে দ্রুত করতে স্ট্রিম করা অডিও লোকালি সংরক্ষণ করুন। + ক্যাশ ফোল্ডার + বর্তমান: %1$s + বর্তমান: ডিফল্ট অ্যাপ্লিকেশন ক্যাশ + ক্যাশ সাইজের সীমা + কোনো সীমা নেই + বর্তমান সীমা: %1$d MB + MB-তে সর্বোচ্চ ক্যাশ সাইজ দিন। সীমাহীন রাখতে 0 দিন বা ফাঁকা রাখুন। + 500 + সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে। + অবিরাম প্লেব্যাক চালু করুন + কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন। + Connect চালু করুন + Spotube Connect ফিচারের মাধ্যমে রিমোট প্লেব্যাক কন্ট্রোল উপলব্ধ করুন। + প্লেব্যাক প্রক্সি সার্ভার পোর্ট + রিমোট কন্ট্রোলের অনুমতি দিন + একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন। + রিমোট কন্ট্রোল ডিভাইসের নাম + অন্য ডিভাইস এই ডিভাইসটি খুঁজে পেলে যে নাম দেখবে। বর্তমান: %1$s + আপনার নেটওয়ার্কের অন্য ডিভাইস এই Spotube খুঁজে পেলে দেখানোর জন্য একটি সহজ নাম বেছে নিন। + যেমন: বসার ঘরের Spotube + ডিভাইসের হোস্টনেম + প্লেব্যাক প্রক্সি সার্ভারের ব্যবহৃত পোর্ট। বর্তমান: %1$d + 1 থেকে 65535-এর মধ্যে একটি পোর্ট বেছে নিন। + 14769 + একটি সঠিক পূর্ণসংখ্যা দিন। + পোর্ট 1 থেকে 65535-এর মধ্যে হতে হবে। + + ব্ল্যাকলিস্ট + ব্ল্যাকলিস্ট করা ট্র্যাক ও শিল্পী ম্যানেজ করুন + + ট্রেতে মিনিমাইজ করুন + উইন্ডো মিনিমাইজ করলে Spotube সিস্টেম ট্রেতে চালু রাখুন। + Discord রিচ প্রেজেন্স + ডেস্কটপে Discord-এ আপনার বর্তমান প্লেব্যাক স্ট্যাটাস দেখান। + + স্বয়ংক্রিয়ভাবে আপডেট চেক করুন + ব্যাকগ্রাউন্ডে Spotube-এর নতুন রিলিজ খুঁজুন। + + প্লাগইন ম্যানেজ করুন + আপনার প্লাগইনগুলো ম্যানেজ করুন + ডিফল্ট %1$s প্লাগইন + মেটাডেটা + অডিও + লিরিক্স + স্ক্রবল + কোনো প্লাগইন নির্বাচিত নয় + পরিবর্তন করুন + নির্বাচন করুন + নির্বাচিত + নির্বাচন মুছুন + কোনো প্লাগইন উপলব্ধ নেই + %1$s প্লাগইন + মুছুন + + কনফিগার করুন + প্লাগইন ম্যানেজার + অনুগ্রহ করে একটি URL দিন + URL অবশ্যই http:// বা https:// দিয়ে শুরু হতে হবে + প্লাগইন ডাউনলোড করা যায়নি + এখনো কোনো প্লাগইন ইনস্টল করা হয়নি + একটি ডাউনলোড URL পেস্ট করুন বা ওপর থেকে একটি ফাইল বেছে নিন। + + %1$dটি প্লাগইন ইনস্টল করা আছে + %1$dটি প্লাগইন ইনস্টল করা আছে + + প্লাগইন ইনস্টল করুন + https://example.com/plugin.smplug + ডাউনলোড + ফাইল থেকে ইনস্টল করুন (.smplug) + সক্রিয় + v%1$s + বিল্ট-ইন + প্লাগইন সরান + লগইন + লগআউট + URL থেকে ডাউনলোড করুন + ফাইল থেকে ইনস্টল করুন + ইনস্টল করুন + + • %1$s + অনুরোধ করা পারমিশন + এই প্লাগইনের কোনো বিশেষ পারমিশন প্রয়োজন নেই। + ইনস্টল করা বনাম প্রদত্ত + ইনস্টল করা + প্রদত্ত + API %1$s → %2$s + স্থায়ী স্টোরেজ + ডিস্কে ডেটা পড়া ও লেখা + নেটওয়ার্ক রিকোয়েস্ট + ইন্টারনেটে ডেটা পাঠানো ও গ্রহণ করা + WebView + অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো + গ্রুপ জ্যাম + MQTT ব্রোকার + ব্রোকার হোস্ট + %1$s:%2$d + ব্রোকার পোর্ট + TLS ব্যবহার করুন + ইউজারনেম (ঐচ্ছিক) + পাসওয়ার্ড (ঐচ্ছিক) + ক্লায়েন্ট ID প্রিফিক্স + এই Spotube শনাক্ত করতে ব্যবহৃত হয় + ব্রোকার কানেকশন এনক্রিপ্ট করুন + রুম শুরু করার আগে বর্তমান ব্রোকার সেটিংস যাচাই করুন। + কানেকশন পরীক্ষা করুন + পরীক্ষা চলছে… + %1$d ms-এ কানেক্ট হয়েছে + ব্যর্থ: %1$s + প্লেসহোল্ডার ব্রোকার — সেলফ-হোস্ট করতে নিজেরটি কনফিগার করুন + কানেকশন + ক্রেডেনশিয়াল + পরিচিতি + ডায়াগনস্টিকস + + + গ্রুপ জ্যাম + তৈরি করুন + যোগ দিন + একটি রুম তৈরি করুন, তারপর এর কোড বন্ধুদের সাথে শেয়ার করুন। + রুম তৈরি করুন + রুমে যোগ দিন + কানেক্ট হচ্ছে… + রুম কোড + ABC123 + ব্রোকার: %1$s + রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি ব্রোকার সেট আপ করুন। + ব্রোকার সেটিংস + কানেক্টেড + আবার কানেক্ট হচ্ছে… + কোড কপি করুন + কপি হয়েছে! + শেয়ার করুন + অংশগ্রহণকারী (%1$d) + হোস্ট + শ্রোতা + শ্রোতাদের জন্য অপেক্ষা করা হচ্ছে… + সরান + ব্যান করুন + ম্যানেজ করুন + রুম ছেড়ে দিন + কানেক্ট করা যায়নি + বাদ দিন + জ্যাম সেশন তৈরি করা যায়নি + জ্যাম সেশনে যোগ দেওয়া যায়নি + অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন। + এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s + + অ্যালবামে যান + স্লিপ টাইমার + ট্র্যাকের বিস্তারিত + প্লে + পজ + কিছুই বাজছে না + কিউতে যোগ করুন + + উপেক্ষা করুন + আপডেট করুন + Spotube %1$s উপলব্ধ + এই রিলিজে নতুন কী আছে তা Spotube ডাউনলোড পেজে দেখুন। + %1$s %2$s উপলব্ধ + বর্তমানে ইনস্টল করা: %1$s + এই আপডেটে নতুন কী আছে তা প্লাগইনের পেজে দেখুন। + + এড়িয়ে যান + পরবর্তী + পেছনে + শুরু করুন + Spotube + মিউজিক, কোনো বাঁধন ছাড়াই। + মিউজিকের জগতের জন্য একটি ওপেন-সোর্স প্লেয়ার।\nআপনার মতো করে ঘুরে দেখুন। নিজের মতো করে সাজিয়ে নিন। + এই প্রজেক্টটি বাঁচিয়ে রাখুন। + Spotube মানুষের তৈরি, কোনো সাবস্ক্রিপশন ব্যবসার নয়। এটি যদি আপনার প্রতিদিনের জীবনের অংশ হয়ে ওঠে, তাহলে একটু সাপোর্ট আমাদের এর প্রাপ্য যত্ন নিতে সাহায্য করে।\n\nস্বাধীন সফটওয়্যারকে জায়গা দেওয়ার জন্য ধন্যবাদ। + গান ও প্লেলিস্ট খুঁজতে একটি ক্যাটালগ প্লাগইন দিয়ে শুরু করুন। সেগুলো বাজাতে একটি অডিও প্লাগইন বেছে নিন। লিরিক্স ও শোনার হিস্ট্রি ঐচ্ছিক। নিচে একটি প্লাগইন খুঁজুন, এর পারমিশন দেখে নিন, ইনস্টল করুন, তারপর এখানে নির্বাচন করুন। + প্লাগইন খুঁজুন + আপনার সোর্স + আবার চেষ্টা করুন + আরও প্লাগইন + নতুন কোনো প্লাগইন পাওয়া যায়নি। ওপরের লিংক বা .smplug ফাইল থেকেও ইনস্টল করতে পারেন। + এই মুহূর্তে প্লাগইন খোঁজা যাচ্ছে না। আবার চেষ্টা করুন, অথবা লিংক বা ফাইল থেকে ইনস্টল করুন। + স্বাগতম + সাপোর্ট + প্লাগইন + গাইড + + Spotube যেভাবে ব্যবহার করবেন + চারটি সহজ ধাপ, তারপরই গান শুনুন। + সাইন ইন + আপনার Spotify অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন। + খুঁজুন ও প্লে করুন + যেকোনো গান, অ্যালবাম বা শিল্পী খুঁজুন, তারপর প্লে চাপুন। + অফলাইনে শুনুন + কানেকশন ছাড়াই শুনতে ট্র্যাক ডাউনলোড করুন। + লিরিক্সের সাথে গান + ট্র্যাক বাজার সময় প্লেয়ার থেকে সিঙ্ক করা লিরিক্স খুলুন। + + + আপনার মিউজিক। আপনার সোর্স। আপনার নিয়ম। + একটি ফ্রি, ওপেন-সোর্স মিউজিক প্লেয়ার, যা তৈরি করেছে মিউজিক ভালোবাসা একটি কমিউনিটি। + ওপেন সোর্স + বিজ্ঞাপন নেই + টেলিমেট্রি নেই + প্লাগইন চালিত + চলুন শুরু করি + + + সমর্থকরাই আমাদের এগিয়ে নেন + ভালোবাসায় তৈরি। আপনার কারণেই টিকে আছে। + Spotube ১০০% ফ্রি, লিব্রে ও ওপেন সোর্স — কোনো বিজ্ঞাপন, সাবস্ক্রিপশন বা ট্র্যাকিং নেই। প্রতিটি ফিচার আছে কারণ আপনার মতো মানুষ কোড চালু রাখতে সাহায্য করেছেন। + Patreon + মাসিক স্পনসর + নিয়মিত ডেভেলপমেন্ট ও ইনফ্রাস্ট্রাকচারে সাপোর্ট করুন + Buy Me a Coffee + এককালীন কফি + একটি ছোট্ট টিপ দিয়ে রাত জাগা কোডিং সেশনে শক্তি জোগান + Open Collective + কমিউনিটি ফান্ড + স্বচ্ছ, কমিউনিটি-পরিচালিত আর্থিক সহায়তা + ওপেন মিউজিকে বিশ্বাস রাখার জন্য অন্তরের অন্তস্তল থেকে ধন্যবাদ। + সাপোর্ট করা সম্পূর্ণ ঐচ্ছিক — যেকোনো অবস্থাতেই Spotube ফ্রি থাকবে। + স্পনসর করুন + %1$s খুলুন + + + Spotube-কে সাপোর্ট করুন + Spotube-কে সাপোর্ট করুন + Spotube ফ্রি, লিব্রে ও ওপেন সোর্স — কোনো বিজ্ঞাপন, সাবস্ক্রিপশন বা ট্র্যাকিং নেই। এটি যদি আপনাকে আনন্দ দেয়, তাহলে প্রজেক্টটিকে সাপোর্ট করার কথা ভাবুন। + এই রিমাইন্ডারগুলো সেটিংস → সাপোর্ট থেকে বন্ধ করতে পারেন। + পরে দেখব + সাপোর্ট রিমাইন্ডার + প্রতি ৭ দিনে প্রজেক্টটিকে সাপোর্ট করার একটি ছোট রিমাইন্ডার দেখান। + + + মডুলার আর্কিটেকচার + আপনার মিউজিক কোথা থেকে আসবে তা বেছে নিন। + Spotube ক্যাটালগ খোঁজা, অডিও স্ট্রিমিং ও সিঙ্ক করা লিরিক্স আলাদা রাখে। নিচে আপনার ইঞ্জিনগুলো কনফিগার করুন বা যেকোনো সময় বদলে নিন। + মেটাডেটা ইঞ্জিন + মিউজিক খোঁজা, সার্চ, ট্র্যাক তালিকা ও প্লেলিস্ট চালায়। + অডিও ইঞ্জিন + যে অডিও স্ট্রিম বাজে তা আনে ও ডিকোড করে। + লিরিক্স ইঞ্জিন + রিয়েল টাইমে সময়-সিঙ্ক করা কারাওকে লিরিক্স দেখায়। + স্ক্রবল ইঞ্জিন + Last.fm বা ListenBrainz-এ শোনার হিস্ট্রি রেকর্ড করে। + সক্রিয় + বিল্ট-ইন + প্রস্তুত + ঐচ্ছিক + একটি ইঞ্জিন বেছে নিন + ইনস্টল করা ইঞ্জিন + ডিফল্ট হিসেবে সেট করুন + NewPipe YouTube ও LRCLib Lyrics বিল্ট-ইন এবং শুরু থেকেই স্ট্রিম করার জন্য প্রস্তুত। + সেটিংস → প্লাগইন থেকে যেকোনো সময় কমিউনিটি প্লাগইন ম্যানেজ বা যোগ করুন। + + + রেট্রো-মডার্ন আর্কিটেকচার + খাঁটি Hi-Fi, কোনো মধ্যস্থতাকারী নেই + Spotube কীভাবে সরাসরি আপনার মেশিনে বিকেন্দ্রীভূত প্লেব্যাক পরিচালনা করে, দেখুন। + সরাসরি পিয়ার-টু-পিয়ার • 256 KBPS OPUS + নীল প্রহর + Spotube · প্লেয়ার ডেমো + মাঝের মুহূর্তগুলোর জন্য একটু মিউজিক। + ১. বিকেন্দ্রীভূত খোঁজ + সার্চ সরাসরি আপনার বেছে নেওয়া ক্যাটালগ প্লাগইনে যায়। Spotube কখনো আপনার রুচির প্রোফাইল তৈরি করে না। + ২. সরাসরি স্ট্রিম রেজোলিউশন + অডিও কনটেন্ট সার্ভার থেকে সরাসরি আপনার মেশিনে স্ট্রিম হয়। কোনো প্রক্সি বা রিমোট ডেটা সংগ্রহ নেই। + ৩. লোকাল DSP ও প্লেব্যাক + ইকুয়ালাইজার কন্ট্রোল ও ঐচ্ছিক অফলাইন ডাউনলোডসহ লোকালি ডিকোড ও ক্যাশ করা হয়। + ৪. সিঙ্ক করা লিরিক্স ও জ্যাম + কারাওকে লিরিক্স প্লেব্যাকের সময়ের সাথে মিলে চলে, সাথে আছে LAN রিমোট কন্ট্রোল ও গ্রুপ জ্যাম। + কোনো মধ্যস্থতাকারী সার্ভার নেই। প্লেব্যাক হয় সরাসরি আপনার ডিভাইসে — ডিজাইনেই প্রাইভেট। + + + একটি ডিভাইস বেছে নিন + %1$s: “%2$s” + প্লে + কিউতে যোগ + পরবর্তীতে প্লে + এই ডিভাইস + এই ডিভাইসে + রিমোট ডিভাইস + রিমোট ডিভাইস (%1$s) + সংযুক্ত ডিভাইসে + + + কানেকশন ব্যর্থ হয়েছে + কানেকশন বিচ্ছিন্ন হয়েছে + পাঠানো যায়নি + খোঁজা ব্যর্থ হয়েছে: %1$s + কানেক্ট করা যায়নি: %1$s + + + ডিভাইস + রিফ্রেশ + %1$s:%2$d-এ কানেক্টেড + ডিসকানেক্ট + কানেক্ট হচ্ছে... + নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে... + কোনো ডিভাইস পাওয়া যায়নি + অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন। + অজানা ডিভাইস + খোঁজা হচ্ছে... + ID: %1$s... + + + রিমোট কন্ট্রোল + ডিসকানেক্টেড + কানেকশন ত্রুটি: %1$s + অ্যালবাম কভার + অজানা ট্র্যাক + অজানা শিল্পী + সিক + শাফল + আগের + পরের + লুপ মোড + ভলিউম + কিউ থেকে সরান + আরও অপশন + + + কোথায় প্লে করবেন? + আপনার একটি জ্যাম সেশন চালু আছে। এই কালেকশন কোথায় প্লে করবেন বেছে নিন। + এখানে প্লে করুন + জ্যামে সাজেস্ট করুন + + + কোনো জ্যাম ব্রোকার কনফিগার করা নেই + ব্রোকার হোস্ট ফাঁকা + কানেকশন ব্যর্থ হয়েছে + কানেকশন বিচ্ছিন্ন হয়েছে + + + ব্রোকার + anonymous + + + জ্যাম কিউতে যোগ করা হয়েছে + + জ্যাম কিউতে %1$dটি যোগ করা হয়েছে + জ্যাম কিউতে %1$dটি যোগ করা হয়েছে + + + + রিমোট কন্ট্রোলের অনুরোধ + \"%1$s\" এই ডিভাইসের প্লেব্যাক নিয়ন্ত্রণ করতে চায়। + প্রত্যাখ্যান + অনুমতি দিন + সবসময় অনুমতি দিন + + + সার্চের ফলাফল + সব + ট্র্যাক + ইউজার + কোনো সার্চ টাইপ উপলব্ধ নেই + খুঁজতে টাইপ করা শুরু করুন + ট্র্যাক খুঁজতে টাইপ করা শুরু করুন + কোনো ফলাফল পাওয়া যায়নি + সব দেখুন + খোঁজা হচ্ছে + ট্র্যাক লোড হচ্ছে... + অ্যালবাম লোড হচ্ছে... + শিল্পী লোড হচ্ছে... + প্লেলিস্ট লোড হচ্ছে... + ইউজার লোড হচ্ছে... + কোনো অ্যালবাম পাওয়া যায়নি + কোনো শিল্পী পাওয়া যায়নি + কোনো প্লেলিস্ট পাওয়া যায়নি + কোনো ইউজার পাওয়া যায়নি + রেকর্ডের বাক্স ঘাঁটা হচ্ছে... + মহাবিশ্বে খোঁজা হচ্ছে... + স্পিকার গরম করা হচ্ছে... + ভাইনিলের ধুলো ঝাড়া হচ্ছে... + সংগীতের দেবতাদের জিজ্ঞেস করা হচ্ছে... + অ্যান্টেনা টিউন করা হচ্ছে... + রেকর্ড উল্টে দেখা হচ্ছে... + সার্চের সক্ষমতা লোড করা যায়নি + সার্চ করা যায়নি + ট্র্যাক খোঁজা যায়নি + অ্যালবাম খোঁজা যায়নি + শিল্পী খোঁজা যায়নি + প্লেলিস্ট খোঁজা যায়নি + ইউজার খোঁজা যায়নি + + + ব্রাউজ + + + সিঙ্কড + সাধারণ + কোনো সিঙ্ক করা লিরিক্স নেই + কোনো সাধারণ লিরিক্স নেই + + + %2$s-এ %1$s + %1$d kbps + %1$s kHz • %2$d ch + কোনো মেটাডেটা প্লাগইন নির্বাচিত নয় + + + পেছনে + আবার চেষ্টা করুন + ডায়ালগ বন্ধ করুন + লোড হচ্ছে... + অজানা + অজানা ত্রুটি + + + ইউজার + প্লেলিস্ট + + + আমরা সত্যিই দুঃখিত + এই মুহূর্তে কিছু একটা ঠিকমতো কাজ করছে না। আপনার অভিজ্ঞতা আমাদের কাছে গুরুত্বপূর্ণ, এবং আমরা এটি ঠিক করার সর্বোচ্চ চেষ্টা করছি। অনুগ্রহ করে আবার চেষ্টা করুন অথবা নিচে ত্রুটির বিস্তারিত দেখুন। + ত্রুটির বিস্তারিত + ক্লিপবোর্ডে কপি করুন + সম্পূর্ণ লগ দেখুন + সম্পূর্ণ ত্রুটি লগ + + + শাফল + শাফল প্লে + ফলো করুন + আনফলো করুন + প্লেলিস্ট এডিট করুন + %1$s-এর + + + ট্র্যাক অপশন + জ্যামে যোগ করুন + রেডিও শুরু করুন + পরবর্তীতে প্লে করুন + পরবর্তীতে সরান + কিউ থেকে সরান + পছন্দের তালিকা থেকে সরান + পছন্দের তালিকায় সেভ করুন + প্লেলিস্টে যোগ করুন + ডাউনলোড + শেয়ার করুন + + + মূল ক্রম + নাম + শিল্পী + অ্যালবাম + দৈর্ঘ্য + সাজান + কোনো ট্র্যাক পাওয়া যায়নি + ফিল্টার + একসাথে অ্যাকশন + সব ডাউনলোড করুন + %1$dটি ডাউনলোড করুন + সব কিউতে যোগ করুন + %1$dটি কিউতে যোগ করুন + সব পরবর্তীতে প্লে করুন + %1$dটি পরবর্তীতে প্লে করুন + সব প্লেলিস্টে যোগ করুন + %1$dটি প্লেলিস্টে যোগ করুন + সব জ্যামে যোগ করুন + %1$dটি জ্যামে যোগ করুন + +আরও %1$d জন + + + লোড করার মতো কোনো URL নেই + ব্রাউজার হিস্ট্রিতে পেছনে যান + ব্রাউজার হিস্ট্রিতে সামনে যান + + + ব্ল্যাকলিস্ট ফিল্টার করুন... + ব্ল্যাকলিস্টে কিছু নেই + \'%1$s\'-এর সাথে মেলে এমন কিছু ব্ল্যাকলিস্টে নেই + ট্র্যাক + ট্র্যাক + ব্ল্যাকলিস্টে যোগ করুন + ব্ল্যাকলিস্ট থেকে সরান + + + ট্র্যাকের কোনো মেটাডেটা নেই + স্ট্রিম URL পাওয়া যায়নি + স্ট্রিম URL পাওয়া যায়নি। প্রথমে ট্র্যাকটি প্লে করে দেখুন। + + + সেভ করা ট্র্যাক + আপনি + + + ফলো করছেন + জনপ্রিয় ট্র্যাক + জনপ্রিয় ট্র্যাক প্লে করুন + জনপ্রিয় ট্র্যাক কিউতে যোগ করুন + সম্পর্কিত শিল্পী + ফিচার্ড প্লেলিস্ট + শিল্পীর ওভারভিউ লোড করা যায়নি + ফলোয়ার সংখ্যা পাওয়া যাচ্ছে না + %1$sK ফলোয়ার + %1$sM ফলোয়ার + %1$sB ফলোয়ার + + %1$d জন ফলোয়ার + %1$d জন ফলোয়ার + + + + লাইক + আনলাইক + No-op ট্র্যাকের নাম + No-op শিল্পী + অজানা অ্যালবাম + সিক + ভলিউম + শাফল চালু করুন + শাফল বন্ধ করুন + আগের + পরের + প্লে বা পজ + লুপ মোড বন্ধ + লুপ মোড: একটি + লুপ মোড: সব + মিউট + আনমিউট + আরও অপশন + প্লেয়ার অপশন + প্লেয়ার ছোট করুন + লিরিক্স বড় করুন + এখন বাজছে + লিরিক্স + বিকল্প সোর্স + বিকল্প সোর্স + ডাউনলোড + ডাউনলোড হয়েছে + ডাউনলোড হচ্ছে... + আগেই ডাউনলোড করা হয়েছে + আবার ডাউনলোডের চেষ্টা হচ্ছে... + %1$s ডাউনলোড হচ্ছে + %1$s পর প্লেব্যাক পজ হবে। + নির্বাচিত মিনিট পরে প্লেব্যাক পজ করুন। + মিনিট + 1 থেকে 600 মিনিটের মধ্যে বেছে নিন + টাইমার বাতিল করুন + শুরু করুন + প্লেব্যাক URL ভিন্ন স্ট্রিমিং সেটিংসে পাওয়া গেছে। হাইলাইট করা স্ট্রিমটি আপনার বর্তমান পছন্দের সাথে মেলে; পরবর্তী স্ট্রিম রিকোয়েস্টে সক্রিয় URL রিফ্রেশ হবে। + মিলে যাওয়া অডিও সোর্স + মিলে যাওয়া সোর্স লোড হচ্ছে… + লোকাল ক্যাশ ফাইল প্লে করার সময় মিলে যাওয়া রিমোট সোর্সের প্রয়োজন নেই। + মিলে যাওয়া কোনো সোর্স উপলব্ধ নেই। + এই ট্র্যাকের জন্য মিলে যাওয়া কোনো অডিও সোর্স ক্যাশ করা নেই। + স্ট্রিমের বিস্তারিত এখনো ক্যাশ করা হয়নি। লোড করতে প্রথমে ট্র্যাকটি প্লে করুন। + ট্র্যাকের বিস্তারিত লোড করা যায়নি। + উপলব্ধ স্ট্রিম + এই ট্র্যাকটি লোকাল ক্যাশ থেকে চালানো হচ্ছে। এই প্লেব্যাকে স্ট্রিম ভ্যারিয়েন্ট ব্যবহৃত হয় না। + স্ট্রিম লোড হচ্ছে… + এই সোর্সের জন্য কোনো স্ট্রিম উপলব্ধ নেই। + বর্তমানে নির্বাচিত স্ট্রিম + আপনার স্ট্রিমিং ফরম্যাট ও কোয়ালিটি পছন্দের সাথে মেলে + সক্রিয় প্লেব্যাক URL + লোকাল ক্যাশ থেকে চালানো হচ্ছে + মেটাডেটা ট্র্যাক + নাম + ID + অ্যালবাম + নির্ভরযোগ্যতা + এক্সটার্নাল URI + অ্যালবাম ID + অ্যালবামের ধরন + অ্যালবাম প্রকাশের তারিখ + অ্যালবামের ট্র্যাক সংখ্যা + অ্যালবামের জনরা + দৈর্ঘ্য + ট্র্যাক নম্বর + ডিস্ক নম্বর + এক্সপ্লিসিট + জনপ্রিয়তা + ISRC + থাম্বনেইল URL + + + সাইডবার টগল করুন + + + বিকল্প সোর্স + এখন কোনো ট্র্যাক বাজছে না + বিকল্প সোর্স খোঁজা হচ্ছে... + কোনো বিকল্প সোর্স পাওয়া যায়নি + নির্ভরযোগ্যতা: %1$d% + সক্রিয় সোর্স + + + বের করে দিন + তাকে সেশন থেকে সরিয়ে দিন + বের করে দিন এবং আবার যোগ দেওয়া আটকান + সাজেশন সরান + তার কিউতে যোগ করা সব ট্র্যাক সরিয়ে দিন + ক্রম বদলান + কিউ থেকে সরান + %1$s • যোগ করেছেন %2$s + + + অজানা + প্লেলিস্ট লোড হচ্ছে... + ট্র্যাক যোগ করুন + প্লেলিস্টে ট্র্যাক যোগ করুন + ট্র্যাক খুঁজুন... + খুঁজুন + কোনো ট্র্যাক পাওয়া যায়নি + যোগ করার জন্য ট্র্যাক খুঁজুন + সম্পন্ন + যোগ করুন + যোগ করা হয়েছে + + + অ্যালবাম লোড হচ্ছে... + + + আপনার প্লেলিস্টে যোগ করুন + খুঁজুন + আপনার প্লেলিস্ট খুঁজুন... + আপনার প্লেলিস্ট লোড হচ্ছে... + আপনার এখনো কোনো প্লেলিস্ট নেই।\nট্র্যাক যোগ করতে একটি তৈরি করুন। + প্লেলিস্ট তৈরি করুন + প্লেলিস্ট এডিট করুন + তৈরি করুন + নাম + প্লেলিস্টের নাম + বিবরণ + বিবরণ (ঐচ্ছিক) + পাবলিক + যে কেউ এই প্লেলিস্ট দেখতে পারবে + কোলাবোরেটিভ + অন্যরা এই প্লেলিস্টে ট্র্যাক যোগ করতে পারবে + কভার ছবি + প্লেলিস্ট কভার + কোনো ছবি নেই + ছবি পরিবর্তন করুন + ছবি বেছে নিন + + + ইনস্টল করুন + প্রতিস্থাপন করুন + zip ফাইলে plugin.json পাওয়া যায়নি + zip ফাইলে manifest.zipline.json পাওয়া যায়নি + plugin.json-এর ফরম্যাট সঠিক নয়: %1$s + প্লাগইন পড়া যায়নি: %1$s + প্লাগইন ইনস্টল করা যায়নি: %1$s + %1$s-এর একটি নতুন ভার্সন উপলব্ধ। ইনস্টল করার আগে আপডেট নিশ্চিত করুন। + %1$s আগেই ইনস্টল করা আছে। ইনস্টল করার আগে প্রতিস্থাপন নিশ্চিত করুন। + বিল্ট-ইন প্লাগইনগুলো আগে থেকেই অন্তর্ভুক্ত, আবার যোগ করা যাবে না। + বিল্ট-ইন প্লাগইন সরানো যাবে না। + %1$s একটি অবৈধ apiVersion (%2$s) ঘোষণা করেছে। %3$s-এর সাথে সামঞ্জস্যপূর্ণ সিমান্টিক ভার্সনিং প্রত্যাশিত। + Spotube প্লাগইন API ভার্সন %1$s অবৈধ। + %1$s প্লাগইন API %2$s-এর জন্য তৈরি, কিন্তু Spotube ব্রেকিং প্লাগইন API %3$s সাপোর্ট করে। একই ব্রেকিং API ভার্সনের জন্য তৈরি প্লাগইন ইনস্টল করুন। + + + প্লাগইন API সামঞ্জস্যপূর্ণ নয় + প্লাগইন ইনস্টল করবেন? + %1$s আপনার ইনস্টল করা প্লাগইনে যোগ হবে। + প্লাগইন আপডেট করবেন? + একটি পুরোনো ভার্সন (%1$s) ইনস্টল করা আছে। %2$s-এ আপডেট করবেন? + ইনস্টল করা প্লাগইন প্রতিস্থাপন করবেন? + %1$s একটি অবৈধ সিমান্টিক ভার্সন (%2$s) ব্যবহার করে, তাই আপডেট হিসেবে তুলনা করা যাচ্ছে না। তবুও ইনস্টল করা প্লাগইন প্রতিস্থাপন করবেন? + ইনস্টল করা ভার্সন (%1$s) সিমান্টিক ভার্সনিং দিয়ে তুলনা করা যাচ্ছে না। এটিকে %2$s দিয়ে প্রতিস্থাপন করবেন? + ভার্সন %1$s আগেই ইনস্টল করা আছে। বিদ্যমান প্লাগইনটি প্রদত্ত কপি দিয়ে প্রতিস্থাপন করবেন? + ইনস্টল করা ভার্সন %1$s প্রদত্ত ভার্সন %2$s-এর চেয়ে নতুন। তবুও প্রতিস্থাপন করবেন? + ইনস্টল করা প্লাগইনটি প্রদত্ত কপি দিয়ে প্রতিস্থাপন করবেন? + + + %1$s: + ভার্সন + API ভার্সন + লেখক + লাইসেন্স + সক্ষমতা + ক্ষমতা + রিপোজিটরি + যোগাযোগ + বাগ রিপোর্ট করুন + %1$s-কে সাপোর্ট করুন + + + অফিশিয়াল + ভেরিফায়েড + GitHub স্টার + GitHub রিপোজিটরি URL + + + কোনো রিলিজ পাওয়া যায়নি + রিলিজ নির্বাচন করুন + একটি রিলিজ বেছে নিন + প্রি-রিলিজ + রিলিজ নোট + + + হোম + সার্চ + এই ডিভাইসে + Connect + ডিভাইস + + + উইন্ডো লুকান + উইন্ডো দেখান + পরের ট্র্যাক + আগের ট্র্যাক + শাফল + লুপ + লুপ: বন্ধ + লুপ: একটি + লুপ: সব + ভলিউম: %1$d% + ভলিউম + + ভলিউম - + মিউট + আনমিউট + ট্র্যাক লাইক করুন + ট্র্যাক আনলাইক করুন + বের হন + + + মিনিমাইজ + ম্যাক্সিমাইজ + রিস্টোর + বন্ধ করুন + + + প্লেব্যাক সার্ভিস + প্লেব্যাক + মিডিয়া প্লেব্যাক কন্ট্রোল + + + ব্রাউজ + এখন বাজছে + ফিচার্ড + সেভ করা ট্র্যাক + জনপ্রিয় ট্র্যাক + diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml new file mode 100644 index 00000000..71f1278a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -0,0 +1,938 @@ + + + + Warteschlange + Warteschlange leeren + Warteschlange filtern... + Keine Einträge in der Warteschlange + + Songs, Künstler, Alben suchen... + Verlauf löschen + Suchen... + + + Deine Bibliothek + Bibliothek + Playlists + Alben + Künstler + Lokale Titel + Downloads + Gespeicherte Playlists durchsuchen… + Gespeicherte Alben durchsuchen… + Gespeicherte Künstler durchsuchen… + Ordner oder Titel durchsuchen… + Bibliothek filtern + Filter schließen + Playlist erstellen + Lokale Titel aktualisieren + Zur Rasteransicht wechseln + Zur Listenansicht wechseln + Keine gespeicherten Playlists + Keine Playlists für „%1$s“ + Keine gespeicherten Alben + Keine Alben für „%1$s“ + Keine gespeicherten Künstler + Keine Künstler für „%1$s“ + + + Ordner + Ordner + %1$s • %2$s + + %1$d Titel + %1$d Titel + + + %1$d Ordner + %1$d Ordner + + Zurück zu den Ordnern + Ordner abspielen + Audiozugriff erforderlich + Erlaube den Audiozugriff, um Musik auf deinem Gerät zu durchsuchen. + Zugriff erlauben + Keine lokalen Ordner gefunden + Keine Titel in diesem Ordner + Keine passenden Ordner oder Titel + Lokale Medien konnten nicht aktualisiert werden + + + %1$d aktiv • %2$d abgeschlossen + Abgeschlossene entfernen + Noch keine Downloads + Wird heruntergeladen: %1$d% + In Warteschlange + Abgeschlossen + Abgebrochen + Fehlgeschlagen + Fehlgeschlagen: %1$s + Download abbrechen + Download wiederholen + Download entfernen + + + Künstler + Auf der Sperrliste + Lieblingssongs + Deine gespeicherten Songs + Unbekannter Künstler + Titelname + Künstlername + + Ausgewählt + + Einstellungen + + Plugins + Sprache und Region + Darstellung + Wiedergabe + Downloads + Zwischenspeicher + Desktop + Updates + Unterstützen + Über + + Schließen + Speichern + Abbrechen + + Sprache + Sprache der App-Oberfläche. Aktuell: %1$s + Systemstandard + Land + Land für Inhalte und Verfügbarkeit. Aktuell: %1$s + %1$s (%2$s) + + Design + Lege fest, wie sich Spotube an das Erscheinungsbild deines Systems anpasst. Aktuell: %1$s + Hell + Dunkel + System + + Oberfläche + Hintergrundmaterial der App. Aktuell: %1$s + Wasser + Eis + Chrom + Acryl + Glas + + Akzentfarbe + Aktuelle Palette: %1$s + Akzentfarbe + Wähle eine Akzentpalette und sieh sie dir im hellen und dunklen Design an. + Hell + Dunkel + Vorschau + Schaltfläche + Koboldgrün + Elektroviolett + Ozeancyan + Abendrot + Rosengarten + Mitternachtsblau + Schiefermetallic + + Look & Feel + Oberflächenmaterial und Akzent. Aktuell: %1$s · %2$s + Look & Feel + Wähle ein Oberflächenmaterial und dann einen passenden Akzent. Jede Option ist eine sorgfältig abgestimmte Kombination. + Oberfläche + Akzent + Sekundär + + Palette + Millennium-Akzentpalette. Aktuell: %1$s + Palette + Wähle eine Millennium-Akzentpalette und sieh sie dir hell und dunkel an. + Palette + Millennium-Palette und Akzent. Aktuell: %1$s + Blau + Orange + Limette + + Eigener Download-Ordner + Aktueller Ordner: %1$s + Aktueller Ordner: Standard-Musikordner + Leer lassen, um den Standard-Musikordner zu verwenden. + C:\Music\Spotube + Lokale Medienordner + Eigene Ordner, die auf dem Desktop durchsucht werden. Hinzugefügt: %1$d + Noch kein eigener Ordner hinzugefügt + Eigene Ordner für deine lokale Musikbibliothek. + Verwalten + Ordner hinzufügen + Ordner entfernen + Keine eigenen Ordner hinzugefügt. + %1$s aus der Suchliste entfernen + Download- und Musik-Cache-Ordner werden automatisch einbezogen. + Dein Download-Ordner wird automatisch einbezogen. + Der Musik-Cache-Ordner wird automatisch einbezogen. + Wird gespeichert… + Ordner konnten nicht gespeichert werden. Deine Änderungen sind noch da – versuche es erneut. + + %1$d Ordner + %1$d Ordner + + Download-Format + Bevorzugter Codec und Container für Downloads. Aktuell: %1$s + Download-Qualität + + Streaming-Format + Bevorzugter Codec und Container für Musik-Streaming. Aktuell: %1$s + Streaming-Qualität + Aktuell: %1$s + Musik zwischenspeichern + Gestreamte Audiodaten lokal speichern, damit sie beim erneuten Abspielen schneller laden. + Cache-Ordner + Aktuell: %1$s + Aktuell: Standard-App-Cache + Cache-Größenlimit + Kein Limit + Aktuelles Limit: %1$d MB + Lege die maximale Cache-Größe in MB fest. Gib 0 ein oder lass das Feld leer für kein Limit. + 500 + Die Größe muss 0 oder eine positive Zahl sein. + Endlose Wiedergabe aktivieren + Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter. + Connect aktivieren + Fernsteuerung der Wiedergabe über Spotube Connect bereitstellen. + Port des Wiedergabe-Proxyservers + Fernsteuerung erlauben + Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden. + Gerätename für die Fernsteuerung + Der Name, den andere Geräte bei der Suche sehen. Aktuell: %1$s + Wähle einen gut erkennbaren Namen, den andere Geräte in deinem Netzwerk sehen, wenn sie diese Spotube-Instanz finden. + z. B. Spotube Wohnzimmer + Hostname des Geräts + Vom Wiedergabe-Proxyserver verwendeter Port. Aktuell: %1$d + Wähle einen Port zwischen 1 und 65535. + 14769 + Gib eine gültige ganze Zahl ein. + Der Port muss zwischen 1 und 65535 liegen. + + Sperrliste + Gesperrte Titel und Künstler verwalten + + In den Infobereich minimieren + Spotube läuft im Infobereich weiter, wenn das Fenster minimiert ist. + Discord Rich Presence + Zeige auf dem Desktop deinen aktuellen Wiedergabestatus in Discord an. + + Automatisch nach Updates suchen + Im Hintergrund nach neuen Spotube-Versionen suchen. + + Plugins verwalten + Verwalte deine Plugins + Standard-Plugin für %1$s + Metadaten + Audio + Songtexte + Scrobbling + Kein Plugin ausgewählt + Ändern + Auswählen + Ausgewählt + Auswahl aufheben + Keine Plugins verfügbar + Plugin %1$s + Löschen + + Konfigurieren + Plugin-Verwaltung + Bitte gib eine URL ein + Die URL muss mit http:// oder https:// beginnen + Plugin konnte nicht heruntergeladen werden + Noch keine Plugins installiert + Füge oben eine Download-URL ein oder wähle eine Datei aus. + + %1$d Plugin installiert + %1$d Plugins installiert + + Plugin installieren + https://example.com/plugin.smplug + Herunterladen + Aus Datei installieren (.smplug) + Aktiv + v%1$s + Integriert + Plugin entfernen + Anmelden + Abmelden + Von URL herunterladen + Aus Datei installieren + Installieren + + • %1$s + Angeforderte Berechtigungen + Dieses Plugin benötigt keine besonderen Berechtigungen. + Installiert vs. neu + Installiert + Neu + API %1$s → %2$s + Dauerhafter Speicher + Daten auf dem Datenträger lesen und schreiben + Netzwerkanfragen + Daten über das Internet senden und empfangen + WebView + Webinhalte in der App anzeigen + Gruppen-Jam + MQTT-Broker + Broker-Host + %1$s:%2$d + Broker-Port + TLS verwenden + Benutzername (optional) + Passwort (optional) + Client-ID-Präfix + Dient zur Identifizierung dieser Spotube-Instanz + Verbindung zum Broker verschlüsseln + Prüfe die aktuellen Broker-Einstellungen, bevor du einen Raum startest. + Verbindung testen + Wird getestet… + Verbunden in %1$d ms + Fehlgeschlagen: %1$s + Platzhalter-Broker – richte deinen eigenen ein, um selbst zu hosten + Verbindung + Zugangsdaten + Identität + Diagnose + + + Gruppen-Jam + Erstellen + Beitreten + Erstelle einen Raum und teile den Code mit deinen Freunden. + Raum erstellen + Raum beitreten + Verbinden… + Raumcode + ABC123 + Broker: %1$s + Richte in den Einstellungen einen Broker ein, um einen Raum zu hosten oder beizutreten. + Broker-Einstellungen + Verbunden + Verbindung wird wiederhergestellt… + Code kopieren + Kopiert! + Teilen + Teilnehmer (%1$d) + Host + Zuhörer + Warte auf Zuhörer… + Entfernen + Sperren + Verwalten + Raum verlassen + Verbindung fehlgeschlagen + Schließen + Jam-Session konnte nicht erstellt werden + Beitritt zur Jam-Session fehlgeschlagen + Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein. + Tritt meiner Spotube-Jam bei, Code: %1$s + + Zum Album + Sleep-Timer + Titeldetails + Abspielen + Pause + Keine Wiedergabe + Zur Warteschlange hinzufügen + + Ignorieren + Aktualisieren + Spotube %1$s ist verfügbar + Die Neuerungen dieser Version findest du auf der Spotube-Downloadseite. + %1$s %2$s ist verfügbar + Aktuell installiert: %1$s + Die Neuerungen dieses Updates findest du auf der Seite des Plugins. + + Überspringen + Weiter + Zurück + Los geht\'s + Spotube + Musik ohne Fesseln. + Ein Open-Source-Player für eine ganze Welt voller Musik.\nZum Entdecken. Zum Selbstgestalten. + Halte dieses Projekt am Leben. + Spotube wird von Menschen gemacht, nicht von einem Abo-Unternehmen. Wenn es Teil deines Alltags wird, hilft uns schon eine kleine Unterstützung, ihm die Pflege zu geben, die es verdient.\n\nDanke, dass du unabhängiger Software einen Platz gibst. + Beginne mit einem Katalog-Plugin, um Songs und Playlists zu finden. Wähle ein Audio-Plugin, um sie abzuspielen. Songtexte und Hörverlauf sind optional. Entdecke unten ein Plugin, prüfe seine Berechtigungen, installiere es und wähle es dann hier aus. + Plugins entdecken + Deine Quellen + Erneut versuchen + Weitere Plugins + Keine neuen Plugins gefunden. Du kannst auch oben eines über einen Link oder eine .smplug-Datei installieren. + Die Plugin-Suche ist gerade nicht verfügbar. Versuche es erneut oder installiere über einen Link oder eine Datei. + Willkommen + Unterstützen + Plugins + Anleitung + + So nutzt du Spotube + Vier schnelle Schritte und du hörst Musik. + Anmelden + Verbinde dein Spotify-Konto oder wähle ein eigenes Katalog-Plugin. + Suchen und abspielen + Finde einen Song, ein Album oder einen Künstler und tippe auf Abspielen. + Offline hören + Lade Titel herunter, um auch ohne Verbindung weiterzuhören. + Songtexte mitlesen + Öffne im Player synchronisierte Songtexte, während ein Titel läuft. + + + Deine Musik. Deine Quellen. Deine Regeln. + Ein kostenloser Open-Source-Musikplayer, entwickelt von einer Community, die Musik einfach liebt. + Open Source + Keine Werbung + Keine Telemetrie + Mit Plugins + Los geht\'s + + + Unterstützer halten uns am Laufen + Mit Leidenschaft gebaut. Von dir am Leben gehalten. + Spotube ist zu 100 % kostenlos, frei und Open Source – keine Werbung, keine Abos, kein Tracking. Jede Funktion gibt es nur, weil Menschen wie du geholfen haben, die Entwicklung am Laufen zu halten. + Patreon + Monatlicher Sponsor + Unterstütze laufende Entwicklung und Infrastruktur + Buy Me a Coffee + Einmaliger Kaffee + Gib einer nächtlichen Coding-Session mit einem kleinen Trinkgeld Energie + Open Collective + Community-Fonds + Transparente, von der Community verwaltete Finanzierung + Von Herzen danke, dass du an offene Musik glaubst. + Die Unterstützung ist völlig freiwillig – Spotube bleibt so oder so kostenlos. + Sponsern + %1$s öffnen + + + Spotube unterstützen + Spotube unterstützen + Spotube ist kostenlos, frei und Open Source – keine Werbung, keine Abos, kein Tracking. Wenn es dir Freude macht, unterstütze doch das Projekt. + Du kannst diese Erinnerungen unter Einstellungen → Unterstützen deaktivieren. + Vielleicht später + Unterstützungs-Erinnerungen + Alle 7 Tage eine dezente Erinnerung anzeigen, das Projekt zu unterstützen. + + + Modulare Architektur + Bestimme, woher deine Musik kommt. + Spotube trennt Katalogsuche, Audio-Streaming und synchronisierte Songtexte. Konfiguriere unten deine Engines oder wechsle sie jederzeit. + Metadaten-Engine + Sorgt für Musikentdeckung, Suche, Titellisten und Playlists. + Audio-Engine + Lädt und dekodiert die abgespielten Audiostreams. + Songtext-Engine + Liefert zeitsynchrone Karaoke-Songtexte in Echtzeit. + Scrobble-Engine + Zeichnet deinen Hörverlauf bei Last.fm oder ListenBrainz auf. + Aktiv + Integriert + Bereit + Optional + Engine auswählen + Installierte Engines + Als Standard festlegen + NewPipe YouTube und LRCLib Lyrics sind integriert und sofort einsatzbereit. + Unter Einstellungen → Plugins kannst du jederzeit Community-Plugins verwalten oder hinzufügen. + + + Retro-moderne Architektur + Reines Hi-Fi, ohne Zwischenhändler + So steuert Spotube die dezentrale Wiedergabe direkt auf deinem Gerät. + DIREKT PEER-TO-PEER • 256 KBPS OPUS + Blaue Stunde + Spotube · Player-Demo + Ein bisschen Musik für die Momente dazwischen. + 1. Dezentrale Entdeckung + Suchanfragen gehen direkt an dein gewähltes Katalog-Plugin. Spotube erstellt nie ein Profil deines Geschmacks. + 2. Direkte Stream-Auflösung + Audio wird direkt von den Content-Servern auf dein Gerät gestreamt. Keine Proxy-Umwege, kein Datensammeln aus der Ferne. + 3. Lokales DSP & Wiedergabe + Lokal dekodiert und zwischengespeichert, mit Equalizer und optionalem Offline-Download. + 4. Synchronisierte Songtexte & Jam + Karaoke-Songtexte folgen der Wiedergabeposition – dazu LAN-Fernsteuerung und Gruppen-Jam. + Keine Zwischenserver. Die Wiedergabe läuft direkt auf deinem Gerät – privat aus Prinzip. + + + Gerät auswählen + %1$s: „%2$s“ + Abspielen + Zur Warteschlange hinzufügen + Als Nächstes abspielen + Dieses Gerät + Auf diesem Gerät + Remote-Gerät + Remote-Gerät (%1$s) + Auf dem verbundenen Gerät + + + Verbindung fehlgeschlagen + Verbindung unterbrochen + Senden fehlgeschlagen + Gerätesuche fehlgeschlagen: %1$s + Verbindung fehlgeschlagen: %1$s + + + Geräte + Aktualisieren + Verbunden mit %1$s:%2$d + Trennen + Verbinden... + Suche nach Geräten im Netzwerk... + Keine Geräte gefunden + Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist. + Unbekanntes Gerät + Wird aufgelöst... + ID: %1$s... + + + Fernsteuerung + Getrennt + Verbindungsfehler: %1$s + Albumcover + Unbekannter Titel + Unbekannter Künstler + Spulen + Zufallswiedergabe + Zurück + Weiter + Wiederholungsmodus + Lautstärke + Aus Warteschlange entfernen + Weitere Optionen + + + Wo abspielen? + Du hast eine aktive Jam-Session. Wähle, wo diese Sammlung abgespielt werden soll. + Hier abspielen + Für Jam vorschlagen + + + Kein Jam-Broker konfiguriert + Broker-Host ist leer + Verbindung fehlgeschlagen + Verbindung unterbrochen + + + Broker + anonym + + + Zur Jam-Warteschlange hinzugefügt + + %1$d Titel zur Jam-Warteschlange hinzugefügt + %1$d Titel zur Jam-Warteschlange hinzugefügt + + + + Fernsteuerungsanfrage + „%1$s“ möchte die Wiedergabe auf diesem Gerät steuern. + Ablehnen + Erlauben + Immer erlauben + + + Suchergebnisse + Alle + Titel + Nutzer + Keine Suchtypen verfügbar + Tippe, um zu suchen + Tippe, um nach Titeln zu suchen + Keine Ergebnisse gefunden + Alle anzeigen + Suche läuft + Titel werden geladen... + Alben werden geladen... + Künstler werden geladen... + Playlists werden geladen... + Nutzer werden geladen... + Keine Alben gefunden + Keine Künstler gefunden + Keine Playlists gefunden + Keine Nutzer gefunden + Wir wühlen in den Plattenkisten... + Wir durchsuchen den Kosmos... + Die Lautsprecher wärmen sich auf... + Wir entstauben das Vinyl... + Wir befragen die Musikgötter... + Wir richten die Antennen aus... + Wir blättern durch die Platten... + Suchfunktionen konnten nicht geladen werden + Suche fehlgeschlagen + Titelsuche fehlgeschlagen + Albumsuche fehlgeschlagen + Künstlersuche fehlgeschlagen + Playlist-Suche fehlgeschlagen + Nutzersuche fehlgeschlagen + + + Entdecken + + + Synchron + Nur Text + Keine synchronisierten Songtexte verfügbar + Keine Songtexte verfügbar + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d Kanäle + Kein Metadaten-Plugin ausgewählt + + + Zurück + Erneut versuchen + Dialog schließen + Wird geladen... + Unbekannt + Unbekannter Fehler + + + Nutzer + Playlist + + + Das tut uns wirklich leid + Gerade funktioniert etwas nicht richtig. Dein Erlebnis ist uns wichtig und wir tun unser Bestes, um das zu beheben. Versuche es bitte erneut oder sieh dir die Fehlerdetails unten an. + Fehlerdetails + In die Zwischenablage kopieren + Vollständiges Protokoll anzeigen + Vollständiges Fehlerprotokoll + + + Zufallswiedergabe + Zufällig abspielen + Folgen + Nicht mehr folgen + Playlist bearbeiten + Von %1$s + + + Titeloptionen + Zur Jam hinzufügen + Radio starten + Als Nächstes abspielen + Als Nächstes einreihen + Aus Warteschlange entfernen + Aus Favoriten entfernen + Als Favorit speichern + Zur Playlist hinzufügen + Herunterladen + Teilen + + + Original + Titel + Künstler + Album + Dauer + Sortieren + Keine Titel gefunden + Filtern + Sammelaktionen + Alle herunterladen + %1$d herunterladen + Alle zur Warteschlange hinzufügen + %1$d zur Warteschlange hinzufügen + Alle als Nächstes abspielen + %1$d als Nächstes abspielen + Alle zur Playlist hinzufügen + %1$d zur Playlist hinzufügen + Alle zur Jam hinzufügen + %1$d zur Jam hinzufügen + +%1$d weitere + + + Keine URL zum Laden + Im Browserverlauf zurück + Im Browserverlauf vorwärts + + + Sperrliste filtern... + Keine gesperrten Einträge + Keine gesperrten Einträge für „%1$s“ + Titel + Titel + Zur Sperrliste hinzufügen + Von der Sperrliste entfernen + + + Keine Titel-Metadaten + Stream-URL konnte nicht ermittelt werden + Stream-URL konnte nicht ermittelt werden. Spiele den Titel zuerst ab. + + + Gespeicherte Titel + Du + + + Gefolgt + Top-Titel + Top-Titel abspielen + Top-Titel zur Warteschlange hinzufügen + Ähnliche Künstler + Empfohlene Playlists + Künstlerübersicht konnte nicht geladen werden + Follower nicht verfügbar + %1$s Tsd. Follower + %1$s Mio. Follower + %1$s Mrd. Follower + + %1$d Follower + %1$d Follower + + + + Gefällt mir + Gefällt mir nicht mehr + Platzhalter-Titel + Platzhalter-Künstler + Unbekanntes Album + Spulen + Lautstärke + Zufallswiedergabe aktivieren + Zufallswiedergabe deaktivieren + Zurück + Weiter + Abspielen oder pausieren + Wiederholen aus + Titel wiederholen + Alle wiederholen + Stummschalten + Stummschaltung aufheben + Weitere Optionen + Player-Optionen + Player minimieren + Songtexte erweitern + Aktuelle Wiedergabe + Songtext + Alternative Quelle + Alternative Quelle + Herunterladen + Heruntergeladen + Wird heruntergeladen... + Bereits heruntergeladen + Download wird wiederholt... + %1$s wird heruntergeladen + Die Wiedergabe wird in %1$s pausiert. + Wiedergabe nach der ausgewählten Anzahl an Minuten pausieren. + Minuten + Zwischen 1 und 600 Minuten wählen + Timer abbrechen + Starten + Die Wiedergabe-URL wurde mit anderen Streaming-Einstellungen ermittelt. Der markierte Stream entspricht deiner aktuellen Einstellung; die aktive URL wird bei der nächsten Stream-Anfrage aktualisiert. + Zugeordnete Audioquelle + Zugeordnete Quelle wird geladen… + Beim Abspielen der lokal zwischengespeicherten Datei wird keine zugeordnete Remote-Quelle benötigt. + Keine zugeordnete Quelle verfügbar. + Für diesen Titel ist keine zugeordnete Audioquelle zwischengespeichert. + Stream-Details sind noch nicht zwischengespeichert. Spiele den Titel zuerst ab, um sie zu laden. + Titeldetails konnten nicht geladen werden. + Verfügbare Streams + Dieser Titel wird aus dem lokalen Cache abgespielt. Stream-Varianten werden für diese Wiedergabe nicht verwendet. + Streams werden geladen… + Für diese Quelle sind keine Streams verfügbar. + Aktuell ausgewählter Stream + Entspricht deinen Einstellungen für Streaming-Format & -Qualität + Aktive Wiedergabe-URL + Aus lokalem Cache abgespielt + Metadaten-Titel + Titel + ID + Album + Übereinstimmung + Externe URI + Album-ID + Albumtyp + Erscheinungsdatum des Albums + Anzahl der Titel im Album + Album-Genres + Dauer + Titelnummer + CD-Nummer + Explizit + Beliebtheit + ISRC + Vorschaubild-URLs + + + Seitenleiste ein-/ausblenden + + + Alternative Quellen + Aktuell wird kein Titel abgespielt + Suche nach alternativen Quellen... + Keine alternativen Quellen gefunden + Übereinstimmung: %1$d% + Aktive Quelle + + + Rauswerfen + Aus der Session entfernen + Rauswerfen und erneuten Beitritt verhindern + Vorschläge entfernen + Alle Titel entfernen, die diese Person zur Warteschlange hinzugefügt hat + Neu anordnen + Aus Warteschlange entfernen + %1$s • Hinzugefügt von %2$s + + + Unbekannt + Playlist wird geladen... + Titel hinzufügen + Titel zur Playlist hinzufügen + Titel suchen... + Suchen + Keine Titel gefunden + Suche nach Titeln zum Hinzufügen + Fertig + Hinzufügen + Hinzugefügt + + + Album wird geladen... + + + Zu deiner Playlist hinzufügen + Suchen + Deine Playlists durchsuchen... + Deine Playlists werden geladen... + Du hast noch keine eigenen Playlists.\nErstelle eine, um Titel hinzuzufügen. + Playlist erstellen + Playlist bearbeiten + Erstellen + Name + Name der Playlist + Beschreibung + Beschreibung (optional) + Öffentlich + Alle können diese Playlist sehen + Gemeinsam + Andere können Titel zu dieser Playlist hinzufügen + Coverbild + Playlist-Cover + Kein Bild + Bild ändern + Bild auswählen + + + Installieren + Ersetzen + plugin.json wurde in der ZIP-Datei nicht gefunden + manifest.zipline.json wurde in der ZIP-Datei nicht gefunden + Ungültiges plugin.json-Format: %1$s + Plugin konnte nicht gelesen werden: %1$s + Plugin konnte nicht installiert werden: %1$s + Eine neuere Version von %1$s ist verfügbar. Bestätige das Update vor der Installation. + %1$s ist bereits installiert. Bestätige das Ersetzen vor der Installation. + Integrierte Plugins sind bereits enthalten und können nicht erneut hinzugefügt werden. + Integrierte Plugins können nicht entfernt werden. + %1$s gibt eine ungültige apiVersion an (%2$s). Erwartet wird semantische Versionierung, kompatibel mit %3$s. + Die Spotube-Plugin-API-Version %1$s ist ungültig. + %1$s ist für Plugin-API %2$s gebaut, Spotube unterstützt jedoch die Breaking-API-Version %3$s. Installiere ein Plugin, das für dieselbe Breaking-API-Version gebaut wurde. + + + Plugin-API nicht kompatibel + Plugin installieren? + %1$s wird zu deinen installierten Plugins hinzugefügt. + Plugin aktualisieren? + Eine ältere Version ist installiert (%1$s). Auf %2$s aktualisieren? + Installiertes Plugin ersetzen? + %1$s verwendet eine ungültige semantische Version (%2$s) und kann nicht als Update verglichen werden. Installiertes Plugin trotzdem ersetzen? + Die installierte Version (%1$s) lässt sich nicht per semantischer Versionierung vergleichen. Durch %2$s ersetzen? + Version %1$s ist bereits installiert. Vorhandenes Plugin durch die neue Kopie ersetzen? + Die installierte Version %1$s ist neuer als die bereitgestellte Version %2$s. Trotzdem ersetzen? + Installiertes Plugin durch die neue Kopie ersetzen? + + + %1$s: + Version + API-Version + Autor + Lizenz + Berechtigungen + Funktionen + Repository + Kontakt + Fehler melden + %1$s unterstützen + + + Offiziell + Verifiziert + GitHub-Sterne + URL des GitHub-Repositorys + + + Keine Releases gefunden + Release auswählen + Wähle ein Release + Vorabversion + Versionshinweise + + + Startseite + Suche + Auf dem Gerät + Verbinden + Geräte + + + Fenster ausblenden + Fenster anzeigen + Nächster Titel + Vorheriger Titel + Zufallswiedergabe + Wiederholen + Wiederholen: AUS + Wiederholen: TITEL + Wiederholen: ALLE + Lautstärke: %1$d% + Lautstärke + + Lautstärke - + Stummschalten + Stummschaltung aufheben + Titel liken + Titel nicht mehr liken + Beenden + + + Minimieren + Maximieren + Wiederherstellen + Schließen + + + Wiedergabedienst + Wiedergabe + Steuerung der Medienwiedergabe + + + Entdecken + Aktuelle Wiedergabe + Empfohlen + Gespeicherte Titel + Top-Titel + diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml new file mode 100644 index 00000000..f6322e76 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -0,0 +1,944 @@ + + + + Cola + Vaciar cola + Filtrar cola... + La cola está vacía + + Busca canciones, artistas, álbumes... + Borrar todo el historial + Buscar... + + + Tu biblioteca + Biblioteca + Playlists + Álbumes + Artistas + Canciones locales + Descargas + Buscar playlists guardadas… + Buscar álbumes guardados… + Buscar artistas guardados… + Buscar carpetas o canciones… + Filtrar biblioteca + Cerrar filtro + Crear playlist + Actualizar canciones locales + Cambiar a vista de cuadrícula + Cambiar a vista de lista + No hay playlists guardadas + Ninguna playlist coincide con “%1$s” + No hay álbumes guardados + Ningún álbum coincide con “%1$s” + No hay artistas guardados + Ningún artista coincide con “%1$s” + + + Carpetas + Carpeta + %1$s • %2$s + + %1$d canción + %1$d canciones + %1$d canciones + + + %1$d carpeta + %1$d carpetas + %1$d carpetas + + Volver a carpetas + Reproducir carpeta + Se necesita acceso al audio + Permite el acceso al audio para explorar la música de tu dispositivo. + Permitir acceso + No se encontraron carpetas locales + No hay canciones en esta carpeta + No hay carpetas ni canciones que coincidan + No se pudo actualizar el contenido local + + + %1$d activas • %2$d completadas + Borrar completadas + Aún no hay descargas + Descargando %1$d% + En cola + Completada + Cancelada + Error + Error: %1$s + Cancelar descarga + Reintentar descarga + Quitar descarga + + + Artista + En lista negra + Canciones que te gustan + Tus canciones guardadas + Artista desconocido + Título de la canción + Nombre del artista + + Seleccionado + + Ajustes + + Plugins + Idioma y región + Apariencia + Reproducción + Descargas + Caché + Escritorio + Actualizaciones + Apoyo + Acerca de + + Cerrar + Guardar + Cancelar + + Idioma + Idioma de la interfaz de la app. Actual: %1$s + Predeterminado del sistema + País + País de referencia para el contenido y la disponibilidad. Actual: %1$s + %1$s (%2$s) + + Tema + Elige cómo se adapta Spotube a la apariencia del sistema. Actual: %1$s + Claro + Oscuro + Sistema + + Superficie + Material de fondo de la app. Actual: %1$s + Acuoso + Helado + Cromo + Acrílico + Cristal + + Color de acento + Paleta actual: %1$s + Color de acento + Elige una paleta de acento y previsualízala en los temas claro y oscuro. + Claro + Oscuro + Vista previa + Botón + Duende verde + Violeta eléctrico + Cian oceánico + Naranja atardecer + Jardín de rosas + Azul medianoche + Pizarra metálica + + Aspecto y estilo + Material de superficie y acento. Actual: %1$s · %2$s + Aspecto y estilo + Elige un material de superficie y luego un acento que combine con él. Cada opción es una combinación seleccionada. + Superficie + Acento + Secundario + + Paleta + Paleta de acento Millennium. Actual: %1$s + Paleta + Elige una paleta de acento Millennium y previsualízala en claro y oscuro. + Paleta + Paleta Millennium y acento. Actual: %1$s + Azul + Naranja + Lima + + Carpeta de descargas personalizada + Carpeta actual: %1$s + Carpeta actual: carpeta de música predeterminada + Déjalo vacío para usar la carpeta de música predeterminada. + C:\Music\Spotube + Carpetas de contenido local + Carpetas personalizadas que se analizan en el escritorio. Añadidas: %1$d + Aún no has añadido ninguna carpeta personalizada + Carpetas personalizadas para tu biblioteca de música local. + Gestionar + Añadir carpeta + Quitar carpeta + No se han añadido carpetas personalizadas. + Quitar %1$s de la lista de análisis + Las carpetas de descargas y de caché de música se incluyen automáticamente. + Tu carpeta de descargas se incluye automáticamente. + La carpeta de caché de música se incluye automáticamente. + Guardando… + No se pudieron guardar las carpetas. Tus cambios siguen aquí; inténtalo de nuevo. + + %1$d carpeta + %1$d carpetas + %1$d carpetas + + Formato de descarga + Códec y contenedor preferidos para las descargas. Actual: %1$s + Calidad de descarga + + Formato de streaming + Códec y contenedor preferidos para el streaming de música. Actual: %1$s + Calidad de streaming + Actual: %1$s + Activar caché de música + Guarda el audio reproducido en local para acelerar las siguientes reproducciones. + Carpeta de caché + Actual: %1$s + Actual: caché predeterminada de la app + Límite de tamaño de la caché + Sin límite + Límite actual: %1$d MB + Define el tamaño máximo de la caché en MB. Escribe 0 o déjalo vacío para no tener límite. + 500 + El tamaño debe ser 0 o un número positivo. + Activar reproducción infinita + Sigue reproduciendo canciones recomendadas cuando termine la cola. + Activar Connect + Ofrece controles de reproducción remota mediante la función Spotube Connect. + Puerto del servidor proxy de reproducción + Permitir control remoto + Permite que otros dispositivos de la misma red controlen la reproducción al conectarse. + Nombre del dispositivo para control remoto + El nombre que ven otros dispositivos al detectar este. Actual: %1$s + Elige un nombre reconocible que verán otros dispositivos de tu red al detectar esta instancia de Spotube. + p. ej., Spotube del salón + nombre de host del dispositivo + Puerto que usa el servidor proxy de reproducción. Actual: %1$d + Elige un puerto entre 1 y 65535. + 14769 + Introduce un número entero válido. + El puerto debe estar entre 1 y 65535. + + Lista negra + Gestiona las canciones y artistas de la lista negra + + Minimizar a la bandeja + Mantén Spotube en ejecución en la bandeja del sistema al minimizar la ventana. + Rich Presence de Discord + Muestra lo que estás escuchando en Discord desde el escritorio. + + Buscar actualizaciones automáticamente + Busca nuevas versiones de Spotube en segundo plano. + + Gestionar plugins + Gestiona tus plugins + Plugin de %1$s predeterminado + Metadatos + Audio + Letras + Scrobbling + Ningún plugin seleccionado + Cambiar + Seleccionar + Seleccionado + Borrar selección + No hay plugins disponibles + Plugin %1$s + Borrar + + Configurar + Gestor de plugins + Introduce una URL + La URL debe empezar por http:// o https:// + No se pudo descargar el plugin + Aún no hay plugins instalados + Pega una URL de descarga o elige un archivo arriba. + + %1$d plugin instalado + %1$d de plugins instalados + %1$d plugins instalados + + Instalar un plugin + https://example.com/plugin.smplug + Descargar + Instalar desde archivo (.smplug) + Activo + v%1$s + Integrado + Quitar plugin + Iniciar sesión + Cerrar sesión + Descargar desde URL + Instalar desde archivo + Instalar + + • %1$s + Permisos solicitados + Este plugin no necesita permisos especiales. + Instalado frente a proporcionado + Instalado + Proporcionado + API %1$s → %2$s + Almacenamiento persistente + Leer y escribir datos en el disco + Solicitudes de red + Enviar y recibir datos por internet + WebView + Mostrar contenido web dentro de la app + Jam en grupo + Broker MQTT + Host del broker + %1$s:%2$d + Puerto del broker + Usar TLS + Usuario (opcional) + Contraseña (opcional) + Prefijo de ID de cliente + Se usa para identificar esta instancia de Spotube + Cifra la conexión con el broker + Comprueba los ajustes actuales del broker antes de crear una sala. + Probar conexión + Probando… + Conectado en %1$d ms + Error: %1$s + Broker de ejemplo: configura el tuyo para alojarlo tú mismo + Conexión + Credenciales + Identidad + Diagnóstico + + + Jam en grupo + Crear + Unirse + Crea una sala y comparte su código con tus amigos. + Crear sala + Unirse a la sala + Conectando… + Código de la sala + ABC123 + Broker: %1$s + Configura un broker en Ajustes para crear una sala o unirte a una. + Ajustes del broker + Conectado + Reconectando… + Copiar código + ¡Copiado! + Compartir + Participantes (%1$d) + Anfitrión + Oyente + Esperando a los oyentes… + Quitar + Bloquear + Gestionar + Salir de la sala + No se pudo conectar + Cerrar + No se pudo crear la sesión de Jam + No se pudo unir a la sesión de Jam + Introduce un código de sala de seis caracteres con letras y números. + Únete a mi Jam de Spotube con el código: %1$s + + Ir al álbum + Temporizador + Detalles de la canción + Reproducir + Pausar + No se está reproduciendo nada + Añadir a la cola + + Ignorar + Actualizar + Spotube %1$s está disponible + Descubre las novedades de esta versión en la página de descargas de Spotube. + %1$s %2$s está disponible + Versión instalada: %1$s + Descubre las novedades de esta actualización en la página del plugin. + + Omitir + Siguiente + Atrás + Empezar + Spotube + Música, sin ataduras. + Un reproductor de código abierto para todo un mundo de música.\nPara que lo explores. Para que lo hagas tuyo. + Mantén vivo este proyecto. + Spotube lo hacen personas, no un negocio de suscripciones. Si se convierte en parte de tu día a día, un pequeño apoyo nos ayuda a darle el cuidado que merece.\n\nGracias por hacerle un hueco al software independiente. + Empieza con un plugin de catálogo para encontrar canciones y playlists. Elige un plugin de audio para reproducirlas. Las letras y el historial de escucha son opcionales. Descubre un plugin abajo, revisa sus permisos, instálalo y selecciónalo aquí. + Descubrir plugins + Tus fuentes + Reintentar + Más plugins + No se encontraron plugins nuevos. También puedes instalar uno desde un enlace o un archivo .smplug arriba. + La búsqueda de plugins no está disponible ahora. Inténtalo de nuevo o instala uno desde un enlace o archivo. + Bienvenida + Apoyo + Plugins + Guía + + Cómo usar Spotube + Cuatro pasos rápidos y a escuchar. + Inicia sesión + Conecta tu cuenta de Spotify o elige tu propio plugin de catálogo. + Busca y reproduce + Encuentra cualquier canción, álbum o artista y dale al play. + Escucha sin conexión + Descarga canciones para seguir escuchando sin conexión. + Sigue la letra + Abre la letra sincronizada desde el reproductor mientras suena una canción. + + + Tu música. Tus fuentes. Tus reglas. + Un reproductor de música gratuito y de código abierto creado por una comunidad que simplemente ama la música. + Código abierto + Sin anuncios + Sin telemetría + Basado en plugins + ¡Vamos! + + + Nuestros mecenas nos mantienen en marcha + Hecho con pasión. Mantenido gracias a ti. + Spotube es 100 % gratuito, libre y de código abierto: sin anuncios, sin suscripciones, sin rastreo. Cada función existe porque personas como tú decidieron ayudar a que el código siga fluyendo. + Patreon + Mecenas mensual + Apoya el desarrollo continuo y la infraestructura + Buy Me a Coffee + Un café puntual + Da energía a una sesión de programación nocturna con una pequeña propina + Open Collective + Fondo comunitario + Financiación transparente y gestionada por la comunidad + Gracias de todo corazón por creer en la música abierta. + Apoyar es totalmente opcional: Spotube seguirá siendo gratis de todos modos. + Patrocinar + Abrir %1$s + + + Apoyar a Spotube + Apoyar a Spotube + Spotube es gratuito, libre y de código abierto: sin anuncios, sin suscripciones, sin rastreo. Si te alegra el día, plantéate apoyar el proyecto. + Puedes desactivar estos recordatorios en Ajustes → Apoyo. + Quizás más tarde + Recordatorios de apoyo + Muestra un recordatorio amable para apoyar el proyecto cada 7 días. + + + Arquitectura modular + Elige de dónde viene tu música. + Spotube separa el catálogo, el streaming de audio y las letras sincronizadas. Configura tus motores abajo o cámbialos cuando quieras. + Motor de metadatos + Se encarga del descubrimiento de música, la búsqueda, las listas de canciones y las playlists. + Motor de audio + Obtiene y decodifica las secuencias de audio que se reproducen. + Motor de letras + Muestra letras de karaoke sincronizadas en tiempo real. + Motor de scrobbling + Registra tu historial de escucha en Last.fm o ListenBrainz. + Activo + Integrado + Listo + Opcional + Selecciona un motor + Motores instalados + Establecer como predeterminado + NewPipe YouTube y LRCLib Lyrics vienen integrados y listos para usar desde el primer momento. + Gestiona o añade nuevos plugins de la comunidad cuando quieras en Ajustes → Plugins. + + + Arquitectura retromoderna + Alta fidelidad pura, sin intermediarios + Así es como Spotube orquesta la reproducción descentralizada directamente en tu equipo. + P2P DIRECTO • OPUS A 256 KBPS + Hora azul + Spotube · demostración del reproductor + Un poco de música para los momentos intermedios. + 1. Descubrimiento descentralizado + Las búsquedas van directamente al plugin de catálogo que elijas. Spotube nunca analiza tus gustos. + 2. Resolución directa del stream + El audio llega directamente de los servidores de contenido a tu equipo. Sin saltos por proxies ni recopilación remota de datos. + 3. DSP y reproducción en local + Decodificado y almacenado en caché localmente, con ecualizador y descarga opcional para escuchar sin conexión. + 4. Letras sincronizadas y Jam + Las letras de karaoke siguen la reproducción al segundo, con control remoto por LAN y Jam en grupo. + Sin servidores intermediarios. La reproducción ocurre en tu dispositivo: privada por diseño. + + + Elige un dispositivo + %1$s: «%2$s» + Reproducir + Añadir a la cola + Reproducir a continuación + Este dispositivo + En este dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + En el dispositivo conectado + + + Error de conexión + Se perdió la conexión + Error al enviar + Error al buscar dispositivos: %1$s + No se pudo conectar: %1$s + + + Dispositivos + Actualizar + Conectado a %1$s:%2$d + Desconectar + Conectando... + Buscando dispositivos en la red... + No se encontraron dispositivos + Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes. + Dispositivo desconocido + Resolviendo... + ID: %1$s... + + + Control remoto + Desconectado + Error de conexión: %1$s + Portada del álbum + Canción desconocida + Artista desconocido + Avanzar + Aleatorio + Anterior + Siguiente + Modo de repetición + Volumen + Quitar de la cola + Más opciones + + + ¿Dónde reproducir? + Tienes una sesión de Jam activa. Elige dónde reproducir esta colección. + Reproducir aquí + Sugerir al Jam + + + No hay ningún broker de Jam configurado + El host del broker está vacío + Error de conexión + Se perdió la conexión + + + Broker + anónimo + + + Añadida a la cola del Jam + + %1$d añadida a la cola del Jam + %1$d añadidas a la cola del Jam + %1$d añadidas a la cola del Jam + + + + Solicitud de control remoto + \"%1$s\" quiere controlar la reproducción en este dispositivo. + Denegar + Permitir + Permitir siempre + + + Resultados de búsqueda + Todo + Canciones + Usuarios + No hay tipos de búsqueda disponibles + Empieza a escribir para buscar + Empieza a escribir para buscar canciones + No se encontraron resultados + Ver todo + Buscando + Cargando canciones... + Cargando álbumes... + Cargando artistas... + Cargando playlists... + Cargando usuarios... + No se encontraron álbumes + No se encontraron artistas + No se encontraron playlists + No se encontraron usuarios + Rebuscando entre los discos... + Buscando por el cosmos... + Calentando los altavoces... + Quitando el polvo a los vinilos... + Consultando a los dioses de la música... + Sintonizando las antenas... + Pasando discos... + No se pudieron cargar las opciones de búsqueda + No se pudo realizar la búsqueda + No se pudieron buscar canciones + No se pudieron buscar álbumes + No se pudieron buscar artistas + No se pudieron buscar playlists + No se pudieron buscar usuarios + + + Explorar + + + Sincronizada + Texto + No hay letra sincronizada disponible + No hay letra disponible + + + %1$s en %2$s + %1$d kbps + %1$s kHz • %2$d canales + No hay ningún plugin de metadatos seleccionado + + + Atrás + Reintentar + Cerrar diálogo + Cargando... + Desconocido + Error desconocido + + + Usuario + Playlist + + + Lo sentimos de verdad + Algo no funciona bien en este momento. Tu experiencia nos importa y estamos haciendo todo lo posible para solucionarlo. Inténtalo de nuevo o consulta los detalles del error a continuación. + Detalles del error + Copiar al portapapeles + Ver registro completo + Registro de errores completo + + + Aleatorio + Reproducción aleatoria + Seguir + Dejar de seguir + Editar playlist + De %1$s + + + Opciones de la canción + Añadir al Jam + Iniciar radio + Reproducir a continuación + Mover a siguiente + Quitar de la cola + Quitar de favoritos + Guardar en favoritos + Añadir a playlist + Descargar + Compartir + + + Original + Título + Artista + Álbum + Duración + Ordenar + No se encontraron canciones + Filtrar + Acciones en bloque + Descargar todo + Descargar %1$d + Añadir todo a la cola + Añadir %1$d a la cola + Reproducir todo a continuación + Reproducir %1$d a continuación + Añadir todo a una playlist + Añadir %1$d a una playlist + Añadir todo al Jam + Añadir %1$d al Jam + +%1$d más + + + No hay ninguna URL que cargar + Retroceder en el historial del navegador + Avanzar en el historial del navegador + + + Filtrar lista negra... + No hay elementos en la lista negra + Ningún elemento de la lista negra coincide con “%1$s” + Canciones + Canción + Añadir a la lista negra + Quitar de la lista negra + + + No hay metadatos de la canción + No se pudo obtener la URL del stream + No se pudo obtener la URL del stream. Prueba a reproducir la canción primero. + + + Canciones guardadas + Tú + + + Siguiendo + Canciones populares + Reproducir canciones populares + Añadir canciones populares a la cola + Artistas similares + Playlists destacadas + No se pudo cargar la información del artista + Seguidores no disponibles + %1$s mil seguidores + %1$s M de seguidores + %1$s mil M de seguidores + + %1$d seguidor + %1$d de seguidores + %1$d seguidores + + + + Me gusta + Ya no me gusta + Título de canción no disponible + Artistas no disponibles + Álbum desconocido + Avanzar + Volumen + Activar aleatorio + Desactivar aleatorio + Anterior + Siguiente + Reproducir o pausar + Repetición desactivada + Repetir una + Repetir todo + Silenciar + Activar sonido + Más opciones + Opciones del reproductor + Contraer reproductor + Ampliar letra + Reproduciendo ahora + Letra + Fuente alternativa + Fuente alternativa + Descargar + Descargada + Descargando... + Ya descargada + Reintentando la descarga... + Descargando %1$s + La reproducción se pausará en %1$s. + Pausa la reproducción tras el número de minutos seleccionado. + Minutos + Elige entre 1 y 600 minutos + Cancelar temporizador + Iniciar + La URL de reproducción se obtuvo con otros ajustes de streaming. El stream resaltado coincide con tu preferencia actual; la URL activa se actualizará en la próxima solicitud de stream. + Fuente de audio asociada + Cargando fuente asociada… + No se necesita una fuente remota asociada mientras se reproduce el archivo local en caché. + No hay ninguna fuente asociada disponible. + No hay ninguna fuente de audio asociada en caché para esta canción. + Los detalles del stream aún no están en caché. Reproduce primero la canción para cargarlos. + No se pudieron cargar los detalles de la canción. + Streams disponibles + Esta canción se está reproduciendo desde la caché local. Las variantes de stream no se usan en esta reproducción. + Cargando streams… + No hay streams disponibles para esta fuente. + Stream seleccionado actualmente + Coincide con tu preferencia de formato y calidad de streaming + URL de reproducción activa + Reproducido desde la caché local + Canción de metadatos + Título + ID + Álbum + Fiabilidad + URI externa + ID del álbum + Tipo de álbum + Fecha de lanzamiento del álbum + Número de canciones del álbum + Géneros del álbum + Duración + Número de pista + Número de disco + Explícito + Popularidad + ISRC + URL de miniaturas + + + Mostrar u ocultar barra lateral + + + Fuentes alternativas + No se está reproduciendo ninguna canción + Buscando fuentes alternativas... + No se encontraron fuentes alternativas + Fiabilidad: %1$d% + Fuente activa + + + Expulsar + Sacarlo de la sesión + Expulsarlo e impedir que vuelva a unirse + Quitar sugerencias + Quitar todas las canciones que añadió a la cola + Reordenar + Quitar de la cola + %1$s • Añadida por %2$s + + + Desconocido + Cargando playlist... + Añadir canciones + Añadir canciones a la playlist + Buscar canciones... + Buscar + No se encontraron canciones + Busca canciones para añadir + Listo + Añadir + Añadida + + + Cargando álbum... + + + Añadir a tu playlist + Buscar + Busca en tus playlists... + Cargando tus playlists... + Aún no tienes ninguna playlist.\nCrea una para añadir canciones. + Crear playlist + Editar playlist + Crear + Nombre + Nombre de la playlist + Descripción + Descripción (opcional) + Pública + Cualquiera puede ver esta playlist + Colaborativa + Otras personas pueden añadir canciones a esta playlist + Imagen de portada + Portada de la playlist + Sin imagen + Cambiar imagen + Elegir imagen + + + Instalar + Reemplazar + No se encontró plugin.json en el archivo zip + No se encontró manifest.zipline.json en el archivo zip + Formato de plugin.json no válido: %1$s + No se pudo leer el plugin: %1$s + No se pudo instalar el plugin: %1$s + Hay una versión más reciente de %1$s. Confirma la actualización antes de instalarla. + %1$s ya está instalado. Confirma que quieres reemplazarlo antes de instalarlo. + Los plugins integrados ya están incluidos y no se pueden volver a añadir. + Los plugins integrados no se pueden quitar. + %1$s declara una apiVersion no válida (%2$s). Se esperaba un versionado semántico compatible con %3$s. + La versión %1$s de la API de plugins de Spotube no es válida. + %1$s está diseñado para la API de plugins %2$s, pero Spotube es compatible con la API de plugins %3$s, que incluye cambios incompatibles. Instala un plugin creado para la misma versión principal de la API. + + + API de plugin no compatible + ¿Instalar plugin? + %1$s se añadirá a tus plugins instalados. + ¿Actualizar plugin? + Hay una versión anterior instalada (%1$s). ¿Actualizar a %2$s? + ¿Reemplazar el plugin instalado? + %1$s usa una versión semántica no válida (%2$s) y no se puede comparar como actualización. ¿Reemplazar el plugin instalado de todos modos? + La versión instalada (%1$s) no se puede comparar con el versionado semántico. ¿Reemplazarla por %2$s? + La versión %1$s ya está instalada. ¿Reemplazar el plugin existente por la copia proporcionada? + La versión instalada %1$s es más reciente que la versión proporcionada %2$s. ¿Reemplazarla de todos modos? + ¿Reemplazar el plugin instalado por la copia proporcionada? + + + %1$s: + Versión + Versión de la API + Autor + Licencia + Capacidades + Funciones + Repositorio + Contacto + Informar de errores + Apoyar a %1$s + + + Oficial + Verificado + Estrellas en GitHub + URL del repositorio de GitHub + + + No se encontraron versiones + Seleccionar versión + Selecciona una versión + Versión preliminar + Notas de la versión + + + Inicio + Buscar + En el dispositivo + Connect + Dispositivos + + + Ocultar ventana + Mostrar ventana + Siguiente canción + Canción anterior + Aleatorio + Repetir + Repetir: NO + Repetir: UNA + Repetir: TODO + Volumen: %1$d% + Volumen + + Volumen - + Silenciar + Activar sonido + Me gusta + Ya no me gusta + Salir + + + Minimizar + Maximizar + Restaurar + Cerrar + + + Servicio de reproducción + Reproducción + Controles de reproducción multimedia + + + Explorar + Reproduciendo ahora + Destacados + Canciones guardadas + Canciones populares + diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml new file mode 100644 index 00000000..89556d91 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -0,0 +1,938 @@ + + + + صف پخش + پاک کردن صف + فیلتر صف... + صف پخش خالی است + + جستجوی آهنگ‌ها، هنرمندان، آلبوم‌ها... + پاک کردن کل تاریخچه + جستجو... + + + کتابخانهٔ شما + کتابخانه + پلی‌لیست‌ها + آلبوم‌ها + هنرمندان + آهنگ‌های محلی + دانلودها + جستجو در پلی‌لیست‌های ذخیره‌شده… + جستجو در آلبوم‌های ذخیره‌شده… + جستجو در هنرمندان ذخیره‌شده… + جستجوی پوشه‌ها یا آهنگ‌ها… + فیلتر کتابخانه + بستن فیلتر + ساخت پلی‌لیست + به‌روزرسانی آهنگ‌های محلی + نمایش شبکه‌ای + نمایش فهرستی + هیچ پلی‌لیست ذخیره‌شده‌ای ندارید + هیچ پلی‌لیستی با «%1$s» مطابقت ندارد + هیچ آلبوم ذخیره‌شده‌ای ندارید + هیچ آلبومی با «%1$s» مطابقت ندارد + هیچ هنرمند ذخیره‌شده‌ای ندارید + هیچ هنرمندی با «%1$s» مطابقت ندارد + + + پوشه‌ها + پوشه + %1$s • %2$s + + %1$d آهنگ + %1$d آهنگ + + + %1$d پوشه + %1$d پوشه + + بازگشت به پوشه‌ها + پخش پوشه + دسترسی به صدا لازم است + برای مرور موسیقی‌های دستگاهتان، اجازهٔ دسترسی به فایل‌های صوتی را بدهید. + اعطای دسترسی + هیچ پوشهٔ محلی‌ای پیدا نشد + این پوشه آهنگی ندارد + هیچ پوشه یا آهنگ مطابقی پیدا نشد + به‌روزرسانی رسانه‌های محلی ناموفق بود + + + %1$d در حال انجام • %2$d تکمیل‌شده + پاک کردن تکمیل‌شده‌ها + هنوز دانلودی ندارید + در حال دانلود %1$d% + در صف + تکمیل شد + لغو شد + ناموفق + ناموفق: %1$s + لغو دانلود + تلاش دوباره برای دانلود + حذف دانلود + + + هنرمند + در فهرست سیاه + آهنگ‌های پسندیده + آهنگ‌های ذخیره‌شدهٔ شما + هنرمند ناشناس + عنوان آهنگ + نام هنرمند + + انتخاب‌شده + + تنظیمات + + افزونه‌ها + زبان و منطقه + ظاهر + پخش + دانلودها + حافظهٔ پنهان + دسکتاپ + به‌روزرسانی‌ها + حمایت + درباره + + بستن + ذخیره + لغو + + زبان + زبان رابط کاربری برنامه. فعلی: %1$s + پیش‌فرض سیستم + کشور + کشور منطقه برای محتوا و دسترس‌پذیری. فعلی: %1$s + %1$s (%2$s) + + پوسته + انتخاب کنید Spotube چگونه با ظاهر سیستم شما هماهنگ شود. فعلی: %1$s + روشن + تیره + سیستم + + سطح + جنس پس‌زمینهٔ برنامه. فعلی: %1$s + آبی‌گون + یخی + کروم + آکریلیک + شیشه + + رنگ تأکیدی + پالت فعلی: %1$s + رنگ تأکیدی + یک پالت رنگ تأکیدی انتخاب کنید و پیش‌نمایش آن را در پوستهٔ روشن و تیره ببینید. + روشن + تیره + پیش‌نمایش + دکمه + گابلین سبز + بنفش الکتریکی + فیروزه‌ای اقیانوسی + نارنجی غروب + باغ رز + آبی نیمه‌شب + خاکستری فلزی + + ظاهر و حس + جنس سطح و رنگ تأکیدی. فعلی: %1$s · %2$s + ظاهر و حس + یک جنس سطح انتخاب کنید، سپس رنگ تأکیدی‌ای که با آن جور باشد. هر گزینه ترکیبی دست‌چین‌شده است. + سطح + رنگ تأکیدی + ثانویه + + پالت + پالت رنگ تأکیدی Millennium. فعلی: %1$s + پالت + یک پالت رنگ تأکیدی Millennium انتخاب کنید و پیش‌نمایش آن را در حالت روشن و تیره ببینید. + پالت + پالت Millennium و رنگ تأکیدی. فعلی: %1$s + آبی + نارنجی + لیمویی + + پوشهٔ دانلود سفارشی + پوشهٔ فعلی: %1$s + پوشهٔ فعلی: پوشهٔ پیش‌فرض موسیقی + برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید. + C:\Music\Spotube + پوشه‌های رسانهٔ محلی + پوشه‌های سفارشی برای اسکن در دسکتاپ. افزوده‌شده: %1$d + هنوز پوشهٔ سفارشی‌ای اضافه نشده است + پوشه‌های سفارشی برای کتابخانهٔ موسیقی محلی شما. + مدیریت + افزودن پوشه + حذف پوشه + هیچ پوشهٔ سفارشی‌ای اضافه نشده است. + حذف %1$s از فهرست اسکن + پوشه‌های دانلود و حافظهٔ پنهان موسیقی به‌طور خودکار اضافه می‌شوند. + پوشهٔ دانلود شما به‌طور خودکار اضافه می‌شود. + پوشهٔ حافظهٔ پنهان موسیقی به‌طور خودکار اضافه می‌شود. + در حال ذخیره… + ذخیرهٔ پوشه‌ها ممکن نشد. تغییرات شما هنوز اینجاست؛ دوباره تلاش کنید. + + %1$d پوشه + %1$d پوشه + + قالب دانلود موسیقی + کدک و قالب ترجیحی برای دانلودها. فعلی: %1$s + کیفیت دانلود موسیقی + + قالب پخش آنلاین موسیقی + کدک و قالب ترجیحی برای پخش آنلاین موسیقی. فعلی: %1$s + کیفیت پخش آنلاین موسیقی + فعلی: %1$s + فعال‌سازی حافظهٔ پنهان موسیقی + صدای پخش‌شده را به‌صورت محلی ذخیره کنید تا پخش دوباره سریع‌تر شود. + پوشهٔ حافظهٔ پنهان + فعلی: %1$s + فعلی: حافظهٔ پنهان پیش‌فرض برنامه + محدودیت حجم حافظهٔ پنهان + بدون محدودیت + محدودیت فعلی: %1$d MB + حداکثر حجم حافظهٔ پنهان را به MB تعیین کنید. برای نامحدود، 0 وارد کنید یا خالی بگذارید. + 500 + حجم باید 0 یا یک عدد مثبت باشد. + فعال‌سازی پخش بی‌پایان + وقتی صف پخش تمام شد، پخش با آهنگ‌های پیشنهادی ادامه پیدا کند. + فعال‌سازی Connect + کنترل‌های پخش از راه دور را از طریق قابلیت Spotube Connect در دسترس قرار دهید. + درگاه سرور پراکسی پخش + اجازهٔ کنترل از راه دور + به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند. + نام دستگاه برای کنترل از راه دور + نامی که دستگاه‌های دیگر هنگام یافتن این دستگاه می‌بینند. فعلی: %1$s + یک نام دوستانه انتخاب کنید تا دستگاه‌های دیگر شبکه هنگام یافتن این نسخه از Spotube آن را ببینند. + مثلاً Spotube اتاق نشیمن + نام میزبان دستگاه + درگاه مورد استفادهٔ سرور پراکسی پخش. فعلی: %1$d + درگاهی بین 1 و 65535 انتخاب کنید. + 14769 + یک عدد صحیح معتبر وارد کنید. + درگاه باید بین 1 و 65535 باشد. + + فهرست سیاه + مدیریت آهنگ‌ها و هنرمندان فهرست سیاه + + کوچک‌سازی به سینی سیستم + وقتی پنجره کوچک می‌شود، Spotube در سینی سیستم در حال اجرا بماند. + وضعیت Discord + وضعیت پخش فعلی خود را در Discord روی دسکتاپ نمایش دهید. + + بررسی خودکار به‌روزرسانی‌ها + نسخه‌های جدید Spotube را در پس‌زمینه بررسی کنید. + + مدیریت افزونه‌ها + افزونه‌های خود را مدیریت کنید + افزونهٔ پیش‌فرض %1$s + فراداده + صدا + متن آهنگ + اسکراب + هیچ افزونه‌ای انتخاب نشده است + تغییر + انتخاب + انتخاب‌شده + پاک کردن انتخاب + هیچ افزونه‌ای در دسترس نیست + افزونهٔ %1$s + پاک کردن + + پیکربندی + مدیریت افزونه‌ها + لطفاً یک URL وارد کنید + URL باید با http:// یا https:// شروع شود + دانلود افزونه ناموفق بود + هنوز افزونه‌ای نصب نشده است + یک URL دانلود وارد کنید یا از بالا فایلی انتخاب کنید. + + %1$d افزونه نصب‌شده + %1$d افزونه نصب‌شده + + نصب افزونه + https://example.com/plugin.smplug + دانلود + نصب از فایل (.smplug) + فعال + v%1$s + داخلی + حذف افزونه + ورود + خروج + دانلود از URL + نصب از فایل + نصب + + • %1$s + مجوزهای درخواستی + این افزونه به مجوز خاصی نیاز ندارد. + نصب‌شده در برابر ارائه‌شده + نصب‌شده + ارائه‌شده + API %1$s → %2$s + ذخیره‌سازی دائمی + خواندن و نوشتن داده روی دیسک + درخواست‌های شبکه + ارسال و دریافت داده از طریق اینترنت + WebView + نمایش محتوای وب درون برنامه + جم گروهی + بروکر MQTT + میزبان بروکر + %1$s:%2$d + درگاه بروکر + استفاده از TLS + نام کاربری (اختیاری) + گذرواژه (اختیاری) + پیشوند شناسهٔ کلاینت + برای شناسایی این نسخه از Spotube استفاده می‌شود + رمزگذاری اتصال به بروکر + پیش از شروع اتاق، تنظیمات فعلی بروکر را بررسی کنید. + آزمایش اتصال + در حال آزمایش… + در %1$d میلی‌ثانیه متصل شد + ناموفق: %1$s + بروکر نمونه — برای میزبانی شخصی، بروکر خودتان را پیکربندی کنید + اتصال + اطلاعات ورود + هویت + عیب‌یابی + + + جم گروهی + ساختن + پیوستن + یک اتاق بسازید و کد آن را با دوستانتان به اشتراک بگذارید. + ساخت اتاق + پیوستن به اتاق + در حال اتصال… + کد اتاق + ABC123 + بروکر: %1$s + برای میزبانی یا پیوستن به اتاق، یک بروکر در تنظیمات راه‌اندازی کنید. + تنظیمات بروکر + متصل + در حال اتصال دوباره… + کپی کد + کپی شد! + اشتراک‌گذاری + شرکت‌کنندگان (%1$d) + میزبان + شنونده + در انتظار شنوندگان… + حذف + مسدود کردن + مدیریت + ترک اتاق + اتصال برقرار نشد + بستن + ساخت جلسهٔ جم ناموفق بود + پیوستن به جلسهٔ جم ناموفق بود + یک کد اتاق شش‌نویسه‌ای شامل حروف و اعداد وارد کنید. + با این کد به جم Spotube من بپیوندید: %1$s + + رفتن به آلبوم + زمان‌سنج خواب + جزئیات آهنگ + پخش + توقف + چیزی در حال پخش نیست + افزودن به صف + + نادیده گرفتن + به‌روزرسانی + Spotube %1$s در دسترس است + تازه‌های این نسخه را در صفحهٔ دانلود Spotube ببینید. + %1$s %2$s در دسترس است + نسخهٔ نصب‌شدهٔ فعلی: %1$s + تازه‌های این به‌روزرسانی را در صفحهٔ افزونه ببینید. + + رد شدن + بعدی + بازگشت + شروع کنید + Spotube + موسیقی، بی‌هیچ قید و بندی. + پخش‌کننده‌ای متن‌باز برای دنیایی از موسیقی.\nبرای کشف کردن. برای از آنِ خود کردن. + این پروژه را زنده نگه دارید. + Spotube را آدم‌ها ساخته‌اند، نه یک کسب‌وکار اشتراکی. اگر بخشی از زندگی روزمره‌تان شده، کمی حمایت به ما کمک می‌کند آن‌طور که شایسته است از آن مراقبت کنیم.\n\nسپاس که برای نرم‌افزار مستقل جا باز می‌کنید. + با یک افزونهٔ کاتالوگ شروع کنید تا آهنگ‌ها و پلی‌لیست‌ها را پیدا کنید. یک افزونهٔ صوتی برای پخش آن‌ها انتخاب کنید. متن آهنگ و تاریخچهٔ شنیدن اختیاری‌اند. افزونه‌ای را در پایین پیدا کنید، مجوزهایش را بررسی کنید، نصبش کنید و سپس آن را اینجا انتخاب کنید. + کشف افزونه‌ها + منابع شما + تلاش دوباره + افزونه‌های بیشتر + افزونهٔ جدیدی پیدا نشد. می‌توانید از طریق پیوند یا فایل .smplug در بالا هم افزونه نصب کنید. + کشف افزونه‌ها در حال حاضر در دسترس نیست. دوباره تلاش کنید یا از پیوند یا فایل نصب کنید. + خوش آمدید + حمایت + افزونه‌ها + راهنما + + نحوهٔ استفاده از Spotube + چهار گام سریع و مشغول شنیدن هستید. + ورود + حساب Spotify خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید. + جستجو و پخش + هر آهنگ، آلبوم یا هنرمندی را پیدا کنید و پخش را بزنید. + گوش دادن آفلاین + آهنگ‌ها را دانلود کنید تا بدون اینترنت هم پخش شوند. + همراهی با متن آهنگ + هنگام پخش آهنگ، متن همگام‌شده را از پخش‌کننده باز کنید. + + + موسیقی شما. منابع شما. قوانین شما. + پخش‌کننده‌ای رایگان و متن‌باز، ساختهٔ جامعه‌ای که فقط عاشق موسیقی است. + متن‌باز + بدون تبلیغات + بدون ردیابی + مبتنی بر افزونه + برویم + + + حامیان ما را سرپا نگه می‌دارند + ساخته‌شده با عشق. زنده به لطف شما. + Spotube صددرصد رایگان، آزاد و متن‌باز است — بدون تبلیغات، بدون اشتراک، بدون ردیابی. هر قابلیتی وجود دارد چون کسانی مثل شما تصمیم گرفتند به ادامهٔ توسعه کمک کنند. + Patreon + حامی ماهانه + از توسعه و زیرساخت مستمر حمایت کنید + Buy Me a Coffee + یک قهوهٔ یک‌باره + با یک انعام کوچک، به یک جلسهٔ کدنویسی شبانه انرژی بدهید + Open Collective + صندوق جامعه + پشتیبانی مالی شفاف و تحت مدیریت جامعه + از صمیم قلب سپاسگزاریم که به موسیقی آزاد باور دارید. + حمایت کاملاً اختیاری است — Spotube در هر صورت رایگان می‌ماند. + حمایت + باز کردن %1$s + + + حمایت از Spotube + حمایت از Spotube + Spotube رایگان، آزاد و متن‌باز است — بدون تبلیغات، بدون اشتراک، بدون ردیابی. اگر برایتان شادی می‌آورد، حمایت از پروژه را در نظر بگیرید. + می‌توانید این یادآوری‌ها را در تنظیمات → حمایت خاموش کنید. + شاید بعداً + یادآوری‌های حمایت + هر ۷ روز یک یادآوری ملایم برای حمایت از پروژه نمایش داده شود. + + + معماری ماژولار + انتخاب کنید موسیقی‌تان از کجا بیاید. + Spotube کشف کاتالوگ، پخش صدا و متن همگام‌شده را از هم جدا می‌کند. موتورهای خود را در پایین پیکربندی کنید یا هر زمان عوضشان کنید. + موتور فراداده + کشف موسیقی، جستجو، فهرست آهنگ‌ها و پلی‌لیست‌ها را ممکن می‌کند. + موتور صدا + جریان‌های صوتی در حال پخش را دریافت و رمزگشایی می‌کند. + موتور متن آهنگ + متن کارائوکهٔ همگام با زمان را به‌صورت زنده نمایش می‌دهد. + موتور اسکراب + تاریخچهٔ شنیدن را در Last.fm یا ListenBrainz ثبت می‌کند. + فعال + داخلی + آماده + اختیاری + یک موتور انتخاب کنید + موتورهای نصب‌شده + تنظیم به‌عنوان پیش‌فرض + NewPipe YouTube و LRCLib Lyrics داخلی هستند و از همان ابتدا آمادهٔ پخش‌اند. + هر زمان در تنظیمات → افزونه‌ها، افزونه‌های جامعه را مدیریت یا اضافه کنید. + + + معماری رترو-مدرن + Hi-Fi خالص، بدون واسطه + این‌گونه Spotube پخش غیرمتمرکز را مستقیماً روی دستگاه شما هماهنگ می‌کند. + نظیربه‌نظیر مستقیم • 256 KBPS OPUS + ساعت آبی + Spotube · نمایش پخش‌کننده + کمی موسیقی برای لحظه‌های میان‌راه. + ۱. کشف غیرمتمرکز + جستجوها مستقیماً به افزونهٔ کاتالوگ انتخابی شما می‌روند. Spotube هرگز از سلیقهٔ شما نمایه نمی‌سازد. + ۲. دریافت مستقیم جریان + صدا مستقیماً از سرورهای محتوا به دستگاه شما می‌رسد. بدون پراکسی میانی یا جمع‌آوری داده از راه دور. + ۳. پردازش صدا و پخش محلی + رمزگشایی و ذخیرهٔ محلی، همراه با اکولایزر و دانلود آفلاین اختیاری. + ۴. متن همگام و جم + متن کارائوکه با زمان پخش همگام می‌شود، همراه با کنترل از راه دور در LAN و جم گروهی. + بدون سرور واسطه. پخش درست روی دستگاه شما انجام می‌شود — خصوصی از پایه. + + + یک دستگاه انتخاب کنید + %1$s: «%2$s» + پخش + افزودن به صف + پخش بعدی + همین دستگاه + روی همین دستگاه + دستگاه راه دور + دستگاه راه دور (%1$s) + روی دستگاه متصل + + + اتصال ناموفق بود + اتصال قطع شد + ارسال ناموفق بود + یافتن دستگاه‌ها ناموفق بود: %1$s + اتصال ناموفق بود: %1$s + + + دستگاه‌ها + به‌روزرسانی + متصل به %1$s:%2$d + قطع اتصال + در حال اتصال... + در حال جستجوی دستگاه‌ها در شبکه... + هیچ دستگاهی پیدا نشد + مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است. + دستگاه ناشناس + در حال شناسایی... + شناسه: %1$s... + + + کنترل از راه دور + قطع شد + خطای اتصال: %1$s + کاور آلبوم + آهنگ ناشناس + هنرمند ناشناس + جابه‌جایی + پخش تصادفی + قبلی + بعدی + حالت تکرار + صدا + حذف از صف + گزینه‌های بیشتر + + + کجا پخش شود؟ + یک جلسهٔ جم فعال دارید. انتخاب کنید این مجموعه کجا پخش شود. + پخش در اینجا + پیشنهاد به جم + + + هیچ بروکر جمی پیکربندی نشده است + میزبان بروکر خالی است + اتصال ناموفق بود + اتصال قطع شد + + + بروکر + ناشناس + + + به صف جم اضافه شد + + %1$d آهنگ به صف جم اضافه شد + %1$d آهنگ به صف جم اضافه شد + + + + درخواست کنترل از راه دور + \"%1$s\" می‌خواهد پخش را روی این دستگاه کنترل کند. + رد کردن + اجازه دادن + همیشه اجازه بده + + + نتایج جستجو + همه + آهنگ‌ها + کاربران + هیچ نوع جستجویی در دسترس نیست + برای جستجو شروع به تایپ کنید + برای جستجوی آهنگ‌ها شروع به تایپ کنید + نتیجه‌ای پیدا نشد + مشاهدهٔ همه + در حال جستجو + در حال بارگیری آهنگ‌ها... + در حال بارگیری آلبوم‌ها... + در حال بارگیری هنرمندان... + در حال بارگیری پلی‌لیست‌ها... + در حال بارگیری کاربران... + هیچ آلبومی پیدا نشد + هیچ هنرمندی پیدا نشد + هیچ پلی‌لیستی پیدا نشد + هیچ کاربری پیدا نشد + در حال گشتن میان صفحه‌ها... + در حال جستجوی کیهان... + در حال گرم کردن بلندگوها... + در حال گردگیری صفحه‌های گرامافون... + در حال مشورت با خدایان موسیقی... + در حال تنظیم آنتن‌ها... + در حال ورق زدن آلبوم‌ها... + بارگیری قابلیت‌های جستجو ناموفق بود + جستجو ناموفق بود + جستجوی آهنگ‌ها ناموفق بود + جستجوی آلبوم‌ها ناموفق بود + جستجوی هنرمندان ناموفق بود + جستجوی پلی‌لیست‌ها ناموفق بود + جستجوی کاربران ناموفق بود + + + مرور + + + همگام + ساده + متن همگام‌شده‌ای در دسترس نیست + متن ساده‌ای در دسترس نیست + + + %1$s در %2$s + %1$d kbps + %1$s kHz • %2$d کانال + هیچ افزونهٔ فراداده‌ای انتخاب نشده است + + + بازگشت + تلاش دوباره + بستن پنجره + در حال بارگیری... + ناشناس + خطای ناشناخته + + + کاربر + پلی‌لیست + + + واقعاً متأسفیم + در حال حاضر چیزی درست کار نمی‌کند. تجربهٔ شما برای ما مهم است و تمام تلاشمان را برای رفع آن می‌کنیم. لطفاً دوباره تلاش کنید یا جزئیات خطا را در پایین بررسی کنید. + جزئیات خطا + کپی در کلیپ‌بورد + مشاهدهٔ گزارش کامل + گزارش کامل خطا + + + پخش تصادفی + پخش تصادفی + دنبال کردن + لغو دنبال کردن + ویرایش پلی‌لیست + از %1$s + + + گزینه‌های آهنگ + افزودن به جم + شروع رادیو + پخش بعدی + انتقال به بعدی + حذف از صف + حذف از علاقه‌مندی‌ها + افزودن به علاقه‌مندی‌ها + افزودن به پلی‌لیست + دانلود + اشتراک‌گذاری + + + اصلی + عنوان + هنرمند + آلبوم + مدت + مرتب‌سازی + هیچ آهنگی پیدا نشد + فیلتر + کارهای گروهی + دانلود همه + دانلود %1$d + افزودن همه به صف + افزودن %1$d به صف + پخش همه در ادامه + پخش %1$d در ادامه + افزودن همه به پلی‌لیست + افزودن %1$d به پلی‌لیست + افزودن همه به جم + افزودن %1$d به جم + +%1$d دیگر + + + URL برای بارگیری وجود ندارد + بازگشت در تاریخچهٔ مرورگر + رفتن به جلو در تاریخچهٔ مرورگر + + + فیلتر فهرست سیاه... + هیچ موردی در فهرست سیاه نیست + هیچ مورد فهرست سیاهی با «%1$s» مطابقت ندارد + آهنگ‌ها + آهنگ + افزودن به فهرست سیاه + حذف از فهرست سیاه + + + فرادادهٔ آهنگ موجود نیست + URL جریان پیدا نشد + URL جریان پیدا نشد. ابتدا آهنگ را پخش کنید. + + + آهنگ‌های ذخیره‌شده + شما + + + دنبال می‌کنید + آهنگ‌های برتر + پخش آهنگ‌های برتر + افزودن آهنگ‌های برتر به صف + هنرمندان مرتبط + پلی‌لیست‌های ویژه + بارگیری نمای کلی هنرمند ناموفق بود + تعداد دنبال‌کنندگان در دسترس نیست + %1$sK دنبال‌کننده + %1$sM دنبال‌کننده + %1$sB دنبال‌کننده + + %1$d دنبال‌کننده + %1$d دنبال‌کننده + + + + پسندیدن + لغو پسند + عنوان آهنگ خالی + هنرمندان خالی + آلبوم ناشناس + جابه‌جایی + صدا + فعال کردن پخش تصادفی + غیرفعال کردن پخش تصادفی + قبلی + بعدی + پخش یا توقف + تکرار خاموش + تکرار یک آهنگ + تکرار همه + بی‌صدا + باصدا + گزینه‌های بیشتر + گزینه‌های پخش‌کننده + جمع کردن پخش‌کننده + باز کردن متن آهنگ + در حال پخش + متن آهنگ + منبع جایگزین + منبع جایگزین + دانلود + دانلود شد + در حال دانلود... + قبلاً دانلود شده است + تلاش دوباره برای دانلود... + در حال دانلود %1$s + پخش تا %1$s دیگر متوقف می‌شود. + پخش پس از تعداد دقیقهٔ انتخاب‌شده متوقف شود. + دقیقه + عددی بین 1 تا 600 دقیقه انتخاب کنید + لغو زمان‌سنج + شروع + URL پخش با تنظیمات پخش آنلاین متفاوتی به دست آمده است. جریان برجسته‌شده با ترجیح فعلی شما مطابقت دارد؛ URL فعال در درخواست بعدی جریان به‌روز می‌شود. + منبع صوتی منطبق + در حال بارگیری منبع منطبق… + هنگام پخش فایل ذخیره‌شدهٔ محلی، به منبع راه دور منطبق نیازی نیست. + هیچ منبع منطبقی در دسترس نیست. + هیچ منبع صوتی منطبقی برای این آهنگ در حافظهٔ پنهان نیست. + جزئیات جریان هنوز در حافظهٔ پنهان نیست. برای بارگیری آن‌ها ابتدا آهنگ را پخش کنید. + بارگیری جزئیات آهنگ ناموفق بود. + جریان‌های در دسترس + این آهنگ از حافظهٔ پنهان محلی پخش می‌شود. نسخه‌های جریان برای این پخش استفاده نمی‌شوند. + در حال بارگیری جریان‌ها… + هیچ جریانی برای این منبع در دسترس نیست. + جریان انتخاب‌شدهٔ فعلی + مطابق با قالب و کیفیت پخش آنلاین ترجیحی شما + URL پخش فعال + پخش از حافظهٔ پنهان محلی + آهنگ فراداده + عنوان + شناسه + آلبوم + اطمینان + URI خارجی + شناسهٔ آلبوم + نوع آلبوم + تاریخ انتشار آلبوم + تعداد آهنگ‌های آلبوم + ژانرهای آلبوم + مدت + شمارهٔ آهنگ + شمارهٔ دیسک + صریح + محبوبیت + ISRC + URLهای تصویر بندانگشتی + + + نمایش/پنهان کردن نوار کناری + + + منابع جایگزین + هیچ آهنگی در حال پخش نیست + در حال جستجوی منابع جایگزین... + هیچ منبع جایگزینی پیدا نشد + اطمینان: %1$d% + منبع فعال + + + بیرون کردن + او را از جلسه حذف کنید + بیرون کردن و جلوگیری از پیوستن دوباره + حذف پیشنهادها + حذف همهٔ آهنگ‌هایی که او به صف اضافه کرده است + تغییر ترتیب + حذف از صف + %1$s • افزوده‌شده توسط %2$s + + + ناشناس + در حال بارگیری پلی‌لیست... + افزودن آهنگ + افزودن آهنگ به پلی‌لیست + جستجوی آهنگ‌ها... + جستجو + هیچ آهنگی پیدا نشد + آهنگ‌هایی را برای افزودن جستجو کنید + تمام + افزودن + اضافه شد + + + در حال بارگیری آلبوم... + + + افزودن به پلی‌لیست شما + جستجو + جستجو در پلی‌لیست‌های شما... + در حال بارگیری پلی‌لیست‌های شما... + هنوز پلی‌لیستی ندارید.\nبرای افزودن آهنگ، یکی بسازید. + ساخت پلی‌لیست + ویرایش پلی‌لیست + ساختن + نام + نام پلی‌لیست + توضیحات + توضیحات (اختیاری) + عمومی + همه می‌توانند این پلی‌لیست را ببینند + مشارکتی + دیگران می‌توانند به این پلی‌لیست آهنگ اضافه کنند + تصویر کاور + کاور پلی‌لیست + بدون تصویر + تغییر تصویر + انتخاب تصویر + + + نصب + جایگزینی + plugin.json در فایل zip پیدا نشد + manifest.zipline.json در فایل zip پیدا نشد + قالب plugin.json نامعتبر است: %1$s + خواندن افزونه ناموفق بود: %1$s + نصب افزونه ناموفق بود: %1$s + نسخهٔ جدیدتری از %1$s در دسترس است. پیش از نصب، به‌روزرسانی را تأیید کنید. + %1$s از قبل نصب شده است. پیش از نصب، جایگزینی آن را تأیید کنید. + افزونه‌های داخلی از قبل وجود دارند و نمی‌توان دوباره اضافه‌شان کرد. + افزونه‌های داخلی را نمی‌توان حذف کرد. + %1$s یک apiVersion نامعتبر (%2$s) اعلام کرده است. نسخه‌بندی معنایی سازگار با %3$s انتظار می‌رود. + نسخهٔ API افزونهٔ Spotube (%1$s) نامعتبر است. + %1$s برای API افزونهٔ %2$s ساخته شده، اما Spotube از نسخهٔ اصلی API افزونهٔ %3$s پشتیبانی می‌کند. افزونه‌ای نصب کنید که برای همان نسخهٔ اصلی API ساخته شده باشد. + + + API افزونه سازگار نیست + افزونه نصب شود؟ + %1$s به افزونه‌های نصب‌شدهٔ شما اضافه می‌شود. + افزونه به‌روز شود؟ + نسخهٔ قدیمی‌تری نصب است (%1$s). به %2$s به‌روز شود؟ + افزونهٔ نصب‌شده جایگزین شود؟ + %1$s از یک نسخهٔ معنایی نامعتبر (%2$s) استفاده می‌کند و نمی‌توان آن را به‌عنوان به‌روزرسانی مقایسه کرد. با این حال افزونهٔ نصب‌شده جایگزین شود؟ + نسخهٔ نصب‌شده (%1$s) را نمی‌توان با نسخه‌بندی معنایی مقایسه کرد. با %2$s جایگزین شود؟ + نسخهٔ %1$s از قبل نصب است. افزونهٔ موجود با نسخهٔ ارائه‌شده جایگزین شود؟ + نسخهٔ نصب‌شدهٔ %1$s از نسخهٔ ارائه‌شدهٔ %2$s جدیدتر است. با این حال جایگزین شود؟ + افزونهٔ نصب‌شده با نسخهٔ ارائه‌شده جایگزین شود؟ + + + %1$s: + نسخه + نسخهٔ API + سازنده + مجوز + قابلیت‌ها + توانایی‌ها + مخزن + تماس + گزارش اشکال + حمایت از %1$s + + + رسمی + تأییدشده + ستاره‌های GitHub + URL مخزن GitHub + + + هیچ نسخه‌ای پیدا نشد + انتخاب نسخه + یک نسخه انتخاب کنید + پیش‌انتشار + یادداشت‌های انتشار + + + خانه + جستجو + روی دستگاه + اتصال + دستگاه‌ها + + + پنهان کردن پنجره + نمایش پنجره + آهنگ بعدی + آهنگ قبلی + پخش تصادفی + تکرار + تکرار: خاموش + تکرار: یک آهنگ + تکرار: همه + صدا: %1$d% + صدا + + صدا - + بی‌صدا + باصدا + پسندیدن آهنگ + لغو پسند آهنگ + خروج + + + کوچک کردن + بزرگ کردن + بازگرداندن + بستن + + + سرویس پخش + پخش + کنترل‌های پخش رسانه + + + مرور + در حال پخش + ویژه + آهنگ‌های ذخیره‌شده + آهنگ‌های برتر + diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml new file mode 100644 index 00000000..9c4bf680 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -0,0 +1,944 @@ + + + + File d\'attente + Vider la file d\'attente + Filtrer la file d\'attente... + La file d\'attente est vide + + Rechercher des titres, artistes, albums... + Effacer tout l\'historique + Rechercher... + + + Bibliothèque + Bibliothèque + Playlists + Albums + Artistes + Titres locaux + Téléchargements + Rechercher dans les playlists enregistrées… + Rechercher dans les albums enregistrés… + Rechercher dans les artistes enregistrés… + Rechercher des dossiers ou des titres… + Filtrer la bibliothèque + Fermer le filtre + Créer une playlist + Actualiser les titres locaux + Afficher en grille + Afficher en liste + Aucune playlist enregistrée + Aucune playlist ne correspond à « %1$s » + Aucun album enregistré + Aucun album ne correspond à « %1$s » + Aucun artiste enregistré + Aucun artiste ne correspond à « %1$s » + + + Dossiers + Dossier + %1$s • %2$s + + %1$d titre + %1$d titres + %1$d titres + + + %1$d dossier + %1$d dossiers + %1$d dossiers + + Retour aux dossiers + Lire le dossier + Accès à l\'audio requis + Autorisez l\'accès à l\'audio pour parcourir la musique de votre appareil. + Autoriser l\'accès + Aucun dossier local trouvé + Aucun titre dans ce dossier + Aucun dossier ou titre correspondant + Échec de l\'actualisation des médias locaux + + + %1$d en cours • %2$d terminé(s) + Effacer les terminés + Aucun téléchargement pour l\'instant + Téléchargement %1$d% + En attente + Terminé + Annulé + Échec + Échec : %1$s + Annuler le téléchargement + Relancer le téléchargement + Supprimer le téléchargement + + + Artiste + Sur liste noire + Titres likés + Vos titres enregistrés + Artiste inconnu + Titre du morceau + Nom de l\'artiste + + Sélectionné + + Paramètres + + Plugins + Langue et région + Apparence + Lecture + Téléchargements + Cache + Bureau + Mises à jour + Soutien + À propos + + Fermer + Enregistrer + Annuler + + Langue + Langue de l\'interface. Actuelle : %1$s + Langue du système + Pays + Pays utilisé pour le contenu et la disponibilité. Actuel : %1$s + %1$s (%2$s) + + Thème + Choisissez comment Spotube s\'adapte à l\'apparence du système. Actuel : %1$s + Clair + Sombre + Système + + Surface + Matériau d\'arrière-plan de l\'application. Actuel : %1$s + Aqueux + Givré + Chrome + Acrylique + Verre + + Couleur d\'accentuation + Palette actuelle : %1$s + Couleur d\'accentuation + Choisissez une palette d\'accentuation et prévisualisez-la en thème clair et sombre. + Clair + Sombre + Aperçu + Bouton + Bouffon vert + Violet électrique + Cyan océan + Orange couchant + Roseraie + Bleu nuit + Ardoise métallique + + Style et rendu + Matériau de surface et accentuation. Actuel : %1$s · %2$s + Style et rendu + Choisissez un matériau de surface, puis une couleur d\'accentuation assortie. Chaque option est une combinaison soigneusement choisie. + Surface + Accentuation + Secondaire + + Palette + Palette d\'accentuation Millennium. Actuelle : %1$s + Palette + Choisissez une palette d\'accentuation Millennium et prévisualisez-la en clair et en sombre. + Palette + Palette Millennium et accentuation. Actuelle : %1$s + Bleu + Orange + Citron vert + + Dossier de téléchargement personnalisé + Dossier actuel : %1$s + Dossier actuel : dossier Musique par défaut + Laissez vide pour utiliser le dossier Musique par défaut. + C:\Music\Spotube + Dossiers de médias locaux + Dossiers personnalisés à analyser sur ordinateur. Ajoutés : %1$d + Aucun dossier personnalisé ajouté + Dossiers personnalisés pour votre bibliothèque musicale locale. + Gérer + Ajouter un dossier + Supprimer le dossier + Aucun dossier personnalisé ajouté. + Retirer %1$s de la liste d\'analyse + Les dossiers de téléchargement et de cache musical sont inclus automatiquement. + Votre dossier de téléchargement est inclus automatiquement. + Le dossier de cache musical est inclus automatiquement. + Enregistrement… + Impossible d\'enregistrer les dossiers. Vos modifications sont conservées ; réessayez. + + %1$d dossier + %1$d dossiers + %1$d dossiers + + Format de téléchargement + Codec et conteneur préférés pour les téléchargements. Actuel : %1$s + Qualité de téléchargement + + Format de streaming + Codec et conteneur préférés pour le streaming. Actuel : %1$s + Qualité de streaming + Actuel : %1$s + Activer le cache musical + Stocker l\'audio diffusé en local pour accélérer les écoutes suivantes. + Dossier du cache + Actuel : %1$s + Actuel : cache par défaut de l\'application + Taille maximale du cache + Aucune limite + Limite actuelle : %1$d MB + Définissez la taille maximale du cache en MB. Saisissez 0 ou laissez vide pour aucune limite. + 500 + La taille doit être 0 ou un nombre positif. + Activer la lecture continue + Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente. + Activer Connect + Proposer des commandes de lecture à distance via la fonctionnalité Spotube Connect. + Port du serveur proxy de lecture + Autoriser le contrôle à distance + Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent. + Nom de l\'appareil pour le contrôle à distance + Le nom que voient les autres appareils lorsqu\'ils détectent celui-ci. Actuel : %1$s + Choisissez un nom convivial à afficher aux autres appareils de votre réseau lorsqu\'ils détectent cette instance de Spotube. + p. ex. Spotube du salon + nom d\'hôte de l\'appareil + Port utilisé par le serveur proxy de lecture. Actuel : %1$d + Choisissez un port entre 1 et 65535. + 14769 + Saisissez un nombre entier valide. + Le port doit être compris entre 1 et 65535. + + Liste noire + Gérer les titres et artistes sur liste noire + + Réduire dans la zone de notification + Garder Spotube actif dans la zone de notification lorsque la fenêtre est réduite. + Statut enrichi Discord + Afficher votre lecture en cours dans Discord sur ordinateur. + + Rechercher automatiquement les mises à jour + Rechercher les nouvelles versions de Spotube en arrière-plan. + + Gérer les plugins + Gérez vos plugins + Plugin %1$s par défaut + Métadonnées + Audio + Paroles + Scrobbling + Aucun plugin sélectionné + Modifier + Sélectionner + Sélectionné + Effacer la sélection + Aucun plugin disponible + Plugin %1$s + Effacer + + Configurer + Gestionnaire de plugins + Veuillez saisir une URL + L\'URL doit commencer par http:// ou https:// + Échec du téléchargement du plugin + Aucun plugin installé pour l\'instant + Collez une URL de téléchargement ou choisissez un fichier ci-dessus. + + %1$d plugin installé + %1$d de plugins installés + %1$d plugins installés + + Installer un plugin + https://example.com/plugin.smplug + Télécharger + Installer depuis un fichier (.smplug) + Actif + v%1$s + Intégré + Supprimer le plugin + Se connecter + Se déconnecter + Télécharger depuis une URL + Installer depuis un fichier + Installer + + • %1$s + Autorisations demandées + Ce plugin ne nécessite aucune autorisation particulière. + Installé ou fourni + Installé + Fourni + API %1$s → %2$s + Stockage persistant + Lire et écrire des données sur le disque + Requêtes réseau + Envoyer et recevoir des données sur Internet + WebView + Afficher du contenu web dans l\'application + Jam de groupe + Broker MQTT + Hôte du broker + %1$s:%2$d + Port du broker + Utiliser TLS + Nom d\'utilisateur (facultatif) + Mot de passe (facultatif) + Préfixe de l\'ID client + Sert à identifier cette instance de Spotube + Chiffrer la connexion au broker + Vérifiez les paramètres actuels du broker avant de créer une salle. + Tester la connexion + Test en cours… + Connecté en %1$d ms + Échec : %1$s + Broker temporaire : configurez le vôtre pour l\'auto-héberger + Connexion + Identifiants + Identité + Diagnostic + + + Jam de groupe + Créer + Rejoindre + Créez une salle, puis partagez son code avec vos amis. + Créer une salle + Rejoindre la salle + Connexion… + Code de la salle + ABC123 + Broker : %1$s + Configurez un broker dans les Paramètres pour créer ou rejoindre une salle. + Paramètres du broker + Connecté + Reconnexion… + Copier le code + Copié ! + Partager + Participants (%1$d) + Hôte + Auditeur + En attente d\'auditeurs… + Retirer + Bannir + Gérer + Quitter la salle + Connexion impossible + Ignorer + Échec de la création de la session Jam + Impossible de rejoindre la session Jam + Saisissez un code de salle de six caractères (lettres et chiffres). + Rejoignez ma Jam Spotube avec le code : %1$s + + Accéder à l\'album + Minuteur de veille + Détails du titre + Lecture + Pause + Aucune lecture en cours + Ajouter à la file d\'attente + + Ignorer + Mettre à jour + Spotube %1$s est disponible + Découvrez les nouveautés de cette version sur la page de téléchargement de Spotube. + %1$s %2$s est disponible + Version installée : %1$s + Découvrez les nouveautés de cette mise à jour sur la page du plugin. + + Passer + Suivant + Retour + Commencer + Spotube + La musique, sans contraintes. + Un lecteur open source pour tout un monde de musique.\nÀ vous de l\'explorer. À vous de vous l\'approprier. + Faites vivre ce projet. + Spotube est créé par des personnes, pas par une entreprise d\'abonnements. S\'il fait partie de votre quotidien, un petit soutien nous aide à lui accorder l\'attention qu\'il mérite.\n\nMerci de faire une place aux logiciels indépendants. + Commencez par un plugin de catalogue pour trouver des titres et des playlists. Choisissez un plugin audio pour les écouter. Les paroles et l\'historique d\'écoute sont facultatifs. Découvrez un plugin ci-dessous, vérifiez ses autorisations, installez-le, puis sélectionnez-le ici. + Découvrir des plugins + Vos sources + Réessayer + Plus de plugins + Aucun nouveau plugin trouvé. Vous pouvez aussi en installer un depuis un lien ou un fichier .smplug ci-dessus. + La découverte de plugins est indisponible pour le moment. Réessayez, ou installez-en un depuis un lien ou un fichier. + Bienvenue + Soutien + Plugins + Guide + + Comment utiliser Spotube + Quatre étapes rapides et c\'est parti. + Se connecter + Connectez votre compte Spotify ou choisissez votre propre plugin de catalogue. + Rechercher et écouter + Trouvez n\'importe quel titre, album ou artiste, puis lancez la lecture. + Écouter hors connexion + Téléchargez des titres pour continuer à écouter sans connexion. + Suivre les paroles + Ouvrez les paroles synchronisées depuis le lecteur pendant la lecture d\'un titre. + + + Votre musique. Vos sources. Vos règles. + Un lecteur de musique gratuit et open source, créé par une communauté qui aime tout simplement la musique. + Open source + Sans publicité + Sans télémétrie + Basé sur des plugins + C\'est parti + + + Nos soutiens nous font avancer + Créé avec passion. Maintenu en vie grâce à vous. + Spotube est 100 % gratuit, libre et open source — sans publicité, sans abonnement, sans pistage. Chaque fonctionnalité existe parce que des personnes comme vous ont choisi d\'aider à faire avancer le code. + Patreon + Soutien mensuel + Soutenez le développement et l\'infrastructure dans la durée + Buy Me a Coffee + Un café ponctuel + Alimentez une session de code nocturne avec un petit pourboire + Open Collective + Fonds communautaire + Un financement transparent, géré par la communauté + Merci du fond du cœur de croire en la musique libre. + Le soutien est entièrement facultatif — Spotube reste gratuit dans tous les cas. + Soutenir + Ouvrir %1$s + + + Soutenir Spotube + Soutenir Spotube + Spotube est gratuit, libre et open source — sans publicité, sans abonnement, sans pistage. S\'il vous apporte de la joie, pensez à soutenir le projet. + Vous pouvez désactiver ces rappels dans Paramètres → Soutien. + Plus tard + Rappels de soutien + Afficher un petit rappel pour soutenir le projet tous les 7 jours. + + + Architecture modulaire + Choisissez d\'où vient votre musique. + Spotube sépare la découverte du catalogue, le streaming audio et les paroles synchronisées. Configurez vos moteurs ci-dessous ou changez-les à tout moment. + Moteur de métadonnées + Gère la découverte musicale, la recherche, les listes de titres et les playlists. + Moteur audio + Récupère et décode les flux audio lus. + Moteur de paroles + Diffuse des paroles karaoké synchronisées en temps réel. + Moteur de scrobbling + Enregistre l\'historique d\'écoute sur Last.fm ou ListenBrainz. + Actif + Intégré + Prêt + Facultatif + Sélectionnez un moteur + Moteurs installés + Définir par défaut + NewPipe YouTube et LRCLib Lyrics sont intégrés et prêts à l\'emploi. + Gérez ou ajoutez des plugins de la communauté à tout moment dans Paramètres → Plugins. + + + Architecture rétro-moderne + Hi-Fi pure, zéro intermédiaire + Voici comment Spotube orchestre une lecture décentralisée directement sur votre machine. + PAIR-À-PAIR DIRECT • OPUS 256 KBPS + L\'heure bleue + Spotube · démonstration du lecteur + Un peu de musique pour les moments entre deux. + 1. Découverte décentralisée + Les requêtes vont directement au plugin de catalogue choisi. Spotube ne profile jamais vos goûts. + 2. Résolution directe des flux + L\'audio est diffusé directement des serveurs de contenu vers votre machine. Aucun proxy ni collecte de données à distance. + 3. DSP et lecture en local + Décodage et mise en cache en local, avec égaliseur et téléchargement hors connexion facultatif. + 4. Paroles synchronisées et Jam + Les paroles karaoké suivent la position de lecture, avec contrôle à distance en LAN et Jam de groupe. + Aucun serveur intermédiaire. La lecture se fait directement sur votre appareil — confidentielle par conception. + + + Choisir un appareil + %1$s : « %2$s » + Lire + Ajouter à la file d\'attente + Lire ensuite + Cet appareil + Sur cet appareil + Appareil distant + Appareil distant (%1$s) + Sur l\'appareil connecté + + + Échec de la connexion + Connexion perdue + Échec de l\'envoi + Échec de la détection : %1$s + Échec de la connexion : %1$s + + + Appareils + Actualiser + Connecté à %1$s:%2$d + Se déconnecter + Connexion... + Recherche d\'appareils sur le réseau... + Aucun appareil trouvé + Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil. + Appareil inconnu + Résolution... + ID : %1$s... + + + Contrôle à distance + Déconnecté + Erreur de connexion : %1$s + Pochette d\'album + Titre inconnu + Artiste inconnu + Avancer/reculer + Lecture aléatoire + Précédent + Suivant + Mode de répétition + Volume + Retirer de la file d\'attente + Plus d\'options + + + Où écouter ? + Une session Jam est en cours. Choisissez où lire cette collection. + Lire ici + Proposer à la Jam + + + Aucun broker Jam configuré + L\'hôte du broker est vide + Échec de la connexion + Connexion perdue + + + Broker + anonyme + + + Ajouté à la file d\'attente de la Jam + + %1$d titre ajouté à la file d\'attente de la Jam + %1$d titres ajoutés à la file d\'attente de la Jam + %1$d titres ajoutés à la file d\'attente de la Jam + + + + Demande de contrôle à distance + \"%1$s\" souhaite contrôler la lecture sur cet appareil. + Refuser + Autoriser + Toujours autoriser + + + Résultats de recherche + Tout + Titres + Utilisateurs + Aucun type de recherche disponible + Commencez à taper pour rechercher + Commencez à taper pour rechercher des titres + Aucun résultat + Tout afficher + Recherche en cours + Chargement des titres... + Chargement des albums... + Chargement des artistes... + Chargement des playlists... + Chargement des utilisateurs... + Aucun album trouvé + Aucun artiste trouvé + Aucune playlist trouvée + Aucun utilisateur trouvé + On fouille dans les bacs... + On explore le cosmos... + On fait chauffer les enceintes... + On dépoussière les vinyles... + On consulte les dieux de la musique... + On règle les antennes... + On feuillette les disques... + Impossible de charger les fonctions de recherche + Échec de la recherche + Échec de la recherche de titres + Échec de la recherche d\'albums + Échec de la recherche d\'artistes + Échec de la recherche de playlists + Échec de la recherche d\'utilisateurs + + + Parcourir + + + Synchronisées + Texte brut + Aucunes paroles synchronisées disponibles + Aucunes paroles disponibles + + + %1$s dans %2$s + %1$d kbps + %1$s kHz • %2$d canaux + Aucun plugin de métadonnées sélectionné + + + Retour + Réessayer + Fermer la boîte de dialogue + Chargement... + Inconnu + Erreur inconnue + + + Utilisateur + Playlist + + + Nous sommes vraiment désolés + Quelque chose ne fonctionne pas pour le moment. Votre expérience compte pour nous et nous faisons de notre mieux pour corriger le problème. Veuillez réessayer ou consulter les détails de l\'erreur ci-dessous. + Détails de l\'erreur + Copier dans le presse-papiers + Voir le journal complet + Journal d\'erreurs complet + + + Lecture aléatoire + Lecture aléatoire + Suivre + Ne plus suivre + Modifier la playlist + Par %1$s + + + Options du titre + Ajouter à la Jam + Lancer la radio + Lire ensuite + Placer en suivant + Retirer de la file d\'attente + Retirer des favoris + Ajouter aux favoris + Ajouter à une playlist + Télécharger + Partager + + + Ordre d\'origine + Titre + Artiste + Album + Durée + Trier + Aucun titre trouvé + Filtrer + Actions groupées + Tout télécharger + Télécharger %1$d + Tout ajouter à la file d\'attente + Ajouter %1$d à la file d\'attente + Tout lire ensuite + Lire %1$d ensuite + Tout ajouter à une playlist + Ajouter %1$d à une playlist + Tout ajouter à la Jam + Ajouter %1$d à la Jam + +%1$d autres + + + Aucune URL à charger + Page précédente + Page suivante + + + Filtrer la liste noire... + Aucun élément sur liste noire + Aucun élément sur liste noire ne correspond à « %1$s » + Titres + Titre + Ajouter à la liste noire + Retirer de la liste noire + + + Aucune métadonnée pour ce titre + Impossible de résoudre l\'URL du flux + Impossible de résoudre l\'URL du flux. Essayez d\'abord de lire le titre. + + + Titres enregistrés + Vous + + + Abonné + Titres populaires + Lire les titres populaires + Ajouter les titres populaires à la file d\'attente + Artistes similaires + Playlists à la une + Impossible de charger la présentation de l\'artiste + Abonnés indisponibles + %1$s k abonnés + %1$s M d\'abonnés + %1$s Md d\'abonnés + + %1$d abonné + %1$d d\'abonnés + %1$d abonnés + + + + J\'aime + Je n\'aime plus + Titre du morceau indisponible + Artistes indisponibles + Album inconnu + Position de lecture + Volume + Activer la lecture aléatoire + Désactiver la lecture aléatoire + Précédent + Suivant + Lecture ou pause + Répétition désactivée + Répéter le titre + Tout répéter + Couper le son + Réactiver le son + Plus d\'options + Options du lecteur + Réduire le lecteur + Agrandir les paroles + En cours de lecture + Paroles + Source alternative + Source alternative + Télécharger + Téléchargé + Téléchargement... + Déjà téléchargé + Nouvelle tentative de téléchargement... + Téléchargement de %1$s + La lecture sera mise en pause dans %1$s. + Mettre la lecture en pause après le nombre de minutes choisi. + Minutes + Entre 1 et 600 minutes + Annuler le minuteur + Démarrer + L\'URL de lecture a été résolue avec d\'autres paramètres de streaming. Le flux mis en évidence correspond à votre préférence actuelle ; l\'URL active sera actualisée à la prochaine demande de flux. + Source audio associée + Chargement de la source associée… + Aucune source distante associée n\'est nécessaire pendant la lecture du fichier en cache local. + Aucune source associée disponible. + Aucune source audio associée n\'est en cache pour ce titre. + Les détails du flux ne sont pas encore en cache. Lisez d\'abord le titre pour les charger. + Impossible de charger les détails du titre. + Flux disponibles + Ce titre est lu depuis le cache local. Les variantes de flux ne sont pas utilisées pour cette lecture. + Chargement des flux… + Aucun flux disponible pour cette source. + Flux actuellement sélectionné + Correspond à vos préférences de format et de qualité de streaming + URL de lecture active + Lu depuis le cache local + Titre (métadonnées) + Titre + ID + Album + Fiabilité + URI externe + ID de l\'album + Type d\'album + Date de sortie de l\'album + Nombre de titres de l\'album + Genres de l\'album + Durée + Numéro de piste + Numéro de disque + Explicite + Popularité + ISRC + URL des miniatures + + + Afficher/masquer la barre latérale + + + Sources alternatives + Aucun titre en cours de lecture + Recherche de sources alternatives... + Aucune source alternative trouvée + Fiabilité : %1$d% + Source active + + + Exclure + Le retirer de la session + L\'exclure et l\'empêcher de revenir + Retirer ses suggestions + Retirer tous les titres qu\'il a ajoutés à la file d\'attente + Réorganiser + Retirer de la file d\'attente + %1$s • Ajouté par %2$s + + + Inconnu + Chargement de la playlist... + Ajouter des titres + Ajouter des titres à la playlist + Rechercher des titres... + Rechercher + Aucun titre trouvé + Recherchez des titres à ajouter + OK + Ajouter + Ajouté + + + Chargement de l\'album... + + + Ajouter à une de vos playlists + Rechercher + Rechercher dans vos playlists... + Chargement de vos playlists... + Vous n\'avez encore aucune playlist.\nCréez-en une pour y ajouter des titres. + Créer une playlist + Modifier la playlist + Créer + Nom + Nom de la playlist + Description + Description (facultatif) + Publique + Tout le monde peut voir cette playlist + Collaborative + D\'autres personnes peuvent ajouter des titres à cette playlist + Image de couverture + Couverture de la playlist + Aucune image + Changer l\'image + Choisir une image + + + Installer + Remplacer + plugin.json introuvable dans le fichier zip + manifest.zipline.json introuvable dans le fichier zip + Format de plugin.json non valide : %1$s + Impossible de lire le plugin : %1$s + Impossible d\'installer le plugin : %1$s + Une version plus récente de %1$s est disponible. Confirmez la mise à jour avant l\'installation. + %1$s est déjà installé. Confirmez le remplacement avant l\'installation. + Les plugins intégrés sont déjà inclus et ne peuvent pas être ajoutés à nouveau. + Les plugins intégrés ne peuvent pas être supprimés. + %1$s déclare une apiVersion non valide (%2$s). Une version sémantique compatible avec %3$s est attendue. + La version %1$s de l\'API de plugins de Spotube n\'est pas valide. + %1$s cible l\'API de plugins %2$s, mais Spotube prend en charge la version majeure %3$s de l\'API de plugins. Installez un plugin conçu pour la même version majeure de l\'API. + + + API de plugin incompatible + Installer le plugin ? + %1$s sera ajouté à vos plugins installés. + Mettre à jour le plugin ? + Une version plus ancienne est installée (%1$s). Mettre à jour vers la version %2$s ? + Remplacer le plugin installé ? + %1$s utilise une version sémantique non valide (%2$s) et ne peut pas être comparé comme une mise à jour. Remplacer quand même le plugin installé ? + La version installée (%1$s) ne peut pas être comparée selon la gestion sémantique de version. La remplacer par %2$s ? + La version %1$s est déjà installée. Remplacer le plugin existant par la copie fournie ? + La version installée %1$s est plus récente que la version fournie %2$s. La remplacer quand même ? + Remplacer le plugin installé par la copie fournie ? + + + %1$s : + Version + Version de l\'API + Auteur + Licence + Capacités + Fonctions + Dépôt + Contact + Signaler un bug + Soutenir %1$s + + + Officiel + Vérifié + Étoiles GitHub + URL du dépôt GitHub + + + Aucune version trouvée + Choisir une version + Sélectionnez une version + Préversion + Notes de version + + + Accueil + Rechercher + Sur l\'appareil + Connect + Appareils + + + Masquer la fenêtre + Afficher la fenêtre + Titre suivant + Titre précédent + Lecture aléatoire + Répétition + Répétition : DÉSACTIVÉE + Répétition : TITRE + Répétition : TOUT + Volume : %1$d% + Volume + + Volume - + Couper le son + Réactiver le son + Aimer le titre + Ne plus aimer le titre + Quitter + + + Réduire + Agrandir + Restaurer + Fermer + + + Service de lecture + Lecture + Commandes de lecture multimédia + + + Parcourir + En cours de lecture + À la une + Titres enregistrés + Titres populaires + diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml new file mode 100644 index 00000000..569467ed --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -0,0 +1,937 @@ + + + कतार + कतार साफ़ करें + कतार फ़िल्टर करें... + कतार में कुछ नहीं है + + गाने, कलाकार, एल्बम खोजें... + पूरा इतिहास साफ़ करें + खोजें... + + + आपकी लाइब्रेरी + लाइब्रेरी + प्लेलिस्ट + एल्बम + कलाकार + लोकल ट्रैक + डाउनलोड + सेव की गई प्लेलिस्ट खोजें… + सेव किए गए एल्बम खोजें… + सेव किए गए कलाकार खोजें… + फ़ोल्डर या ट्रैक खोजें… + लाइब्रेरी फ़िल्टर करें + फ़िल्टर बंद करें + प्लेलिस्ट बनाएं + लोकल ट्रैक रीफ़्रेश करें + ग्रिड व्यू पर जाएं + लिस्ट व्यू पर जाएं + कोई सेव की गई प्लेलिस्ट नहीं + “%1$s” से मेल खाती कोई प्लेलिस्ट नहीं + कोई सेव किया गया एल्बम नहीं + “%1$s” से मेल खाता कोई एल्बम नहीं + कोई सेव किया गया कलाकार नहीं + “%1$s” से मेल खाता कोई कलाकार नहीं + + + फ़ोल्डर + फ़ोल्डर + %1$s • %2$s + + %1$d ट्रैक + %1$d ट्रैक + + + %1$d फ़ोल्डर + %1$d फ़ोल्डर + + फ़ोल्डर पर वापस जाएं + फ़ोल्डर चलाएं + ऑडियो एक्सेस ज़रूरी है + अपने डिवाइस का संगीत देखने के लिए ऑडियो एक्सेस की अनुमति दें। + एक्सेस दें + कोई लोकल फ़ोल्डर नहीं मिला + इस फ़ोल्डर में कोई ट्रैक नहीं है + कोई मेल खाता फ़ोल्डर या ट्रैक नहीं + लोकल मीडिया रीफ़्रेश नहीं हो सका + + + %1$d जारी • %2$d पूरे + पूरे हुए हटाएं + अभी कोई डाउनलोड नहीं + डाउनलोड हो रहा है %1$d% + कतार में + पूरा हुआ + रद्द किया गया + विफल + विफल: %1$s + डाउनलोड रद्द करें + फिर से डाउनलोड करें + डाउनलोड हटाएं + + + कलाकार + ब्लैकलिस्ट में + पसंद किए गए ट्रैक + आपके सेव किए गए गाने + अज्ञात कलाकार + ट्रैक का नाम + कलाकार का नाम + + चुना गया + + सेटिंग + + प्लगइन + भाषा और क्षेत्र + दिखावट + प्लेबैक + डाउनलोड + कैशिंग + डेस्कटॉप + अपडेट + सहयोग + जानकारी + + बंद करें + सेव करें + रद्द करें + + भाषा + ऐप इंटरफ़ेस की भाषा। मौजूदा: %1$s + सिस्टम डिफ़ॉल्ट + देश + कंटेंट और उपलब्धता के लिए क्षेत्रीय देश। मौजूदा: %1$s + %1$s (%2$s) + + थीम + चुनें कि Spotube आपके सिस्टम की दिखावट से कैसे मेल खाए। मौजूदा: %1$s + लाइट + डार्क + सिस्टम + + सरफ़ेस + ऐप का बैकग्राउंड मटीरियल। मौजूदा: %1$s + जलीय + बर्फ़ीला + क्रोम + एक्रिलिक + ग्लास + + एक्सेंट रंग + मौजूदा पैलेट: %1$s + एक्सेंट रंग + एक्सेंट पैलेट चुनें और उसे लाइट और डार्क दोनों थीम में देखें। + लाइट + डार्क + प्रीव्यू + बटन + ग्रीन गॉब्लिन + इलेक्ट्रिक वायलेट + ओशनिक सियान + सनसेट ऑरेंज + रोज़ गार्डन + मिडनाइट ब्लू + मेटैलिक स्लेट + + लुक और फ़ील + सरफ़ेस मटीरियल और एक्सेंट। मौजूदा: %1$s · %2$s + लुक और फ़ील + एक सरफ़ेस मटीरियल चुनें, फिर उसके साथ जंचने वाला एक्सेंट। हर विकल्प एक चुना हुआ कॉम्बिनेशन है। + सरफ़ेस + एक्सेंट + सेकेंडरी + + पैलेट + Millennium एक्सेंट पैलेट। मौजूदा: %1$s + पैलेट + Millennium एक्सेंट पैलेट चुनें और उसे लाइट और डार्क में देखें। + पैलेट + Millennium पैलेट और एक्सेंट। मौजूदा: %1$s + नीला + नारंगी + लाइम + + डाउनलोड फ़ोल्डर बदलें + मौजूदा फ़ोल्डर: %1$s + मौजूदा फ़ोल्डर: डिफ़ॉल्ट म्यूज़िक फ़ोल्डर + डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें। + C:\Music\Spotube + लोकल मीडिया फ़ोल्डर + डेस्कटॉप पर स्कैन करने के लिए कस्टम फ़ोल्डर। जोड़े गए: %1$d + अभी तक कोई कस्टम फ़ोल्डर नहीं जोड़ा गया + आपकी लोकल म्यूज़िक लाइब्रेरी के लिए कस्टम फ़ोल्डर। + मैनेज करें + फ़ोल्डर जोड़ें + फ़ोल्डर हटाएं + कोई कस्टम फ़ोल्डर नहीं जोड़ा गया। + %1$s को स्कैन सूची से हटाएं + डाउनलोड और म्यूज़िक कैश फ़ोल्डर अपने-आप शामिल होते हैं। + आपका डाउनलोड फ़ोल्डर अपने-आप शामिल होता है। + म्यूज़िक कैश फ़ोल्डर अपने-आप शामिल होता है। + सेव हो रहा है… + फ़ोल्डर सेव नहीं हो सके। आपके बदलाव अभी भी यहीं हैं; फिर से कोशिश करें। + + %1$d फ़ोल्डर + %1$d फ़ोल्डर + + डाउनलोड म्यूज़िक फ़ॉर्मैट + डाउनलोड के लिए पसंदीदा कोडेक और कंटेनर। मौजूदा: %1$s + डाउनलोड म्यूज़िक क्वालिटी + + स्ट्रीमिंग म्यूज़िक फ़ॉर्मैट + म्यूज़िक स्ट्रीमिंग के लिए पसंदीदा कोडेक और कंटेनर। मौजूदा: %1$s + स्ट्रीमिंग म्यूज़िक क्वालिटी + मौजूदा: %1$s + म्यूज़िक कैशिंग चालू करें + दोबारा तेज़ी से चलाने के लिए स्ट्रीम किया गया ऑडियो डिवाइस पर सेव करें। + कैश फ़ोल्डर + मौजूदा: %1$s + मौजूदा: डिफ़ॉल्ट ऐप्लिकेशन कैश + कैश साइज़ सीमा + कोई सीमा नहीं + मौजूदा सीमा: %1$d MB + MB में अधिकतम कैश साइज़ सेट करें। कोई सीमा न रखने के लिए 0 डालें या खाली छोड़ें। + 500 + साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए। + लगातार प्लेबैक चालू करें + कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें। + Connect चालू करें + Spotube Connect फ़ीचर के ज़रिए रिमोट प्लेबैक कंट्रोल उपलब्ध कराएं। + प्लेबैक प्रॉक्सी सर्वर पोर्ट + रिमोट कंट्रोल की अनुमति दें + एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें। + रिमोट कंट्रोल डिवाइस का नाम + यह नाम दूसरे डिवाइस को इस डिवाइस को खोजते समय दिखता है। मौजूदा: %1$s + एक आसान नाम चुनें, जो आपके नेटवर्क के दूसरे डिवाइस को यह Spotube खोजते समय दिखे। + जैसे लिविंग रूम Spotube + डिवाइस होस्टनेम + प्लेबैक प्रॉक्सी सर्वर का पोर्ट। मौजूदा: %1$d + 1 से 65535 के बीच कोई पोर्ट चुनें। + 14769 + एक मान्य पूर्ण संख्या डालें। + पोर्ट 1 से 65535 के बीच होना चाहिए। + + ब्लैकलिस्ट + ब्लैकलिस्ट किए गए ट्रैक और कलाकार मैनेज करें + + ट्रे में छोटा करें + विंडो छोटी करने पर Spotube को सिस्टम ट्रे में चलता रहने दें। + Discord रिच प्रेज़ेंस + डेस्कटॉप पर Discord में आपका मौजूदा प्लेबैक स्टेटस दिखाएं। + + अपडेट अपने-आप जांचें + बैकग्राउंड में Spotube के नए रिलीज़ देखें। + + प्लगइन मैनेज करें + अपने प्लगइन मैनेज करें + डिफ़ॉल्ट %1$s प्लगइन + मेटाडेटा + ऑडियो + लिरिक्स + स्क्रॉबल + कोई प्लगइन नहीं चुना गया + बदलें + चुनें + चुना गया + चुनाव हटाएं + कोई प्लगइन उपलब्ध नहीं + %1$s प्लगइन + हटाएं + + कॉन्फ़िगर करें + प्लगइन मैनेजर + कृपया URL डालें + URL http:// या https:// से शुरू होना चाहिए + प्लगइन डाउनलोड नहीं हो सका + अभी तक कोई प्लगइन इंस्टॉल नहीं है + डाउनलोड URL पेस्ट करें या ऊपर से कोई फ़ाइल चुनें। + + %1$d प्लगइन इंस्टॉल है + %1$d प्लगइन इंस्टॉल हैं + + प्लगइन इंस्टॉल करें + https://example.com/plugin.smplug + डाउनलोड करें + फ़ाइल से इंस्टॉल करें (.smplug) + सक्रिय + v%1$s + बिल्ट-इन + प्लगइन हटाएं + लॉग इन करें + लॉग आउट करें + URL से डाउनलोड करें + फ़ाइल से इंस्टॉल करें + इंस्टॉल करें + + • %1$s + मांगी गई अनुमतियां + इस प्लगइन को किसी खास अनुमति की ज़रूरत नहीं है। + इंस्टॉल बनाम दिया गया + इंस्टॉल किया गया + दिया गया + API %1$s → %2$s + स्थायी स्टोरेज + डिस्क पर डेटा पढ़ें और लिखें + नेटवर्क अनुरोध + इंटरनेट पर डेटा भेजें और पाएं + WebView + ऐप के अंदर वेब कंटेंट दिखाएं + ग्रुप जैम + MQTT ब्रोकर + ब्रोकर होस्ट + %1$s:%2$d + ब्रोकर पोर्ट + TLS इस्तेमाल करें + यूज़रनेम (वैकल्पिक) + पासवर्ड (वैकल्पिक) + क्लाइंट ID प्रीफ़िक्स + इस Spotube की पहचान के लिए इस्तेमाल होता है + ब्रोकर कनेक्शन एन्क्रिप्ट करें + रूम शुरू करने से पहले मौजूदा ब्रोकर सेटिंग जांचें। + कनेक्शन टेस्ट करें + टेस्ट हो रहा है… + %1$d ms में कनेक्ट हुआ + विफल: %1$s + प्लेसहोल्डर ब्रोकर — खुद होस्ट करने के लिए अपना ब्रोकर सेट करें + कनेक्शन + क्रेडेंशियल + पहचान + डायग्नोस्टिक्स + + + ग्रुप जैम + बनाएं + जुड़ें + एक रूम बनाएं, फिर उसका कोड अपने दोस्तों के साथ शेयर करें। + रूम बनाएं + रूम से जुड़ें + कनेक्ट हो रहा है… + रूम कोड + ABC123 + ब्रोकर: %1$s + रूम होस्ट करने या उससे जुड़ने के लिए सेटिंग में ब्रोकर सेट करें। + ब्रोकर सेटिंग + कनेक्ट है + फिर से कनेक्ट हो रहा है… + कोड कॉपी करें + कॉपी हो गया! + शेयर करें + सदस्य (%1$d) + होस्ट + श्रोता + श्रोताओं का इंतज़ार है… + हटाएं + बैन करें + मैनेज करें + रूम छोड़ें + कनेक्ट नहीं हो सका + ख़ारिज करें + जैम सेशन नहीं बन सका + जैम सेशन से नहीं जुड़ सके + अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें। + मेरे Spotube जैम से जुड़ें, कोड: %1$s + + एल्बम पर जाएं + स्लीप टाइमर + ट्रैक की जानकारी + चलाएं + रोकें + कुछ नहीं चल रहा + कतार में जोड़ें + + अनदेखा करें + अपडेट करें + Spotube %1$s उपलब्ध है + इस रिलीज़ में क्या नया है, यह Spotube डाउनलोड पेज पर देखें। + %1$s %2$s उपलब्ध है + अभी इंस्टॉल है: %1$s + इस अपडेट में क्या नया है, यह प्लगइन के पेज पर देखें। + + छोड़ें + आगे + पीछे + शुरू करें + Spotube + संगीत, बिना किसी बंधन के। + संगीत की दुनिया के लिए एक ओपन-सोर्स प्लेयर।\nखोजिए अपने तरीके से। बनाइए इसे अपना। + इस प्रोजेक्ट को चलते रहने दें। + Spotube को लोग बनाते हैं, कोई सब्सक्रिप्शन बिज़नेस नहीं। अगर यह आपकी रोज़ की ज़िंदगी का हिस्सा बन जाए, तो थोड़ा सा सहयोग हमें इसकी वह देखभाल करने में मदद करता है जिसका यह हकदार है।\n\nस्वतंत्र सॉफ़्टवेयर को जगह देने के लिए धन्यवाद। + गाने और प्लेलिस्ट ढूंढने के लिए किसी कैटलॉग प्लगइन से शुरुआत करें। उन्हें चलाने के लिए एक ऑडियो प्लगइन चुनें। लिरिक्स और सुनने का इतिहास वैकल्पिक हैं। नीचे कोई प्लगइन खोजें, उसकी अनुमतियां देखें, इंस्टॉल करें, फिर उसे यहां चुनें। + प्लगइन खोजें + आपके सोर्स + फिर से कोशिश करें + और प्लगइन + कोई नया प्लगइन नहीं मिला। आप ऊपर दिए लिंक या .smplug फ़ाइल से भी इंस्टॉल कर सकते हैं। + प्लगइन खोज अभी उपलब्ध नहीं है। फिर से कोशिश करें, या लिंक या फ़ाइल से इंस्टॉल करें। + स्वागत + सहयोग + प्लगइन + गाइड + + Spotube कैसे इस्तेमाल करें + चार आसान स्टेप और आप सुनना शुरू कर सकते हैं। + साइन इन करें + अपना Spotify अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें। + खोजें और चलाएं + कोई भी गाना, एल्बम या कलाकार खोजें, फिर प्ले दबाएं। + ऑफ़लाइन सुनें + बिना कनेक्शन के सुनते रहने के लिए ट्रैक डाउनलोड करें। + लिरिक्स के साथ गाएं + ट्रैक चलते समय प्लेयर से सिंक्ड लिरिक्स खोलें। + + + आपका संगीत। आपके सोर्स। आपके नियम। + एक मुफ़्त, ओपन-सोर्स म्यूज़िक प्लेयर, जिसे संगीत से प्यार करने वाली कम्युनिटी ने बनाया है। + ओपन सोर्स + कोई विज्ञापन नहीं + कोई टेलीमेट्री नहीं + प्लगइन से चलने वाला + चलिए शुरू करें + + + सहयोगियों से हम चलते हैं + जुनून से बना। आपसे ज़िंदा। + Spotube 100% मुफ़्त, स्वतंत्र और ओपन सोर्स है — न विज्ञापन, न सब्सक्रिप्शन, न ट्रैकिंग। हर फ़ीचर इसलिए है क्योंकि आप जैसे लोगों ने कोड को आगे बढ़ाने में मदद करना चुना। + Patreon + मासिक स्पॉन्सर + लगातार डेवलपमेंट और इंफ्रास्ट्रक्चर में सहयोग करें + Buy Me a Coffee + एक बार की कॉफ़ी + एक छोटी सी टिप से देर रात की कोडिंग को ऊर्जा दें + Open Collective + कम्युनिटी फ़ंड + पारदर्शी, कम्युनिटी द्वारा संचालित आर्थिक सहयोग + ओपन म्यूज़िक पर भरोसा करने के लिए दिल से धन्यवाद। + सहयोग करना पूरी तरह वैकल्पिक है — Spotube हर हाल में मुफ़्त रहेगा। + स्पॉन्सर करें + %1$s खोलें + + + Spotube को सहयोग दें + Spotube को सहयोग दें + Spotube मुफ़्त, स्वतंत्र और ओपन सोर्स है — न विज्ञापन, न सब्सक्रिप्शन, न ट्रैकिंग। अगर यह आपको खुशी देता है, तो प्रोजेक्ट को सहयोग देने के बारे में सोचें। + आप ये रिमाइंडर सेटिंग → सहयोग में बंद कर सकते हैं। + बाद में + सहयोग रिमाइंडर + हर 7 दिन में प्रोजेक्ट को सहयोग देने का एक छोटा सा रिमाइंडर दिखाएं। + + + मॉड्यूलर आर्किटेक्चर + चुनें कि आपका संगीत कहां से आए। + Spotube कैटलॉग खोज, ऑडियो स्ट्रीमिंग और सिंक्ड लिरिक्स को अलग रखता है। नीचे अपने इंजन सेट करें या उन्हें कभी भी बदलें। + मेटाडेटा इंजन + म्यूज़िक खोज, सर्च, ट्रैक लिस्टिंग और प्लेलिस्ट को चलाता है। + ऑडियो इंजन + चलने वाली ऑडियो स्ट्रीम लाता और डिकोड करता है। + लिरिक्स इंजन + समय के साथ सिंक कराओके लिरिक्स रियल टाइम में दिखाता है। + स्क्रॉबल इंजन + सुनने का इतिहास Last.fm या ListenBrainz पर दर्ज करता है। + सक्रिय + बिल्ट-इन + तैयार + वैकल्पिक + एक इंजन चुनें + इंस्टॉल किए गए इंजन + डिफ़ॉल्ट बनाएं + NewPipe YouTube और LRCLib लिरिक्स बिल्ट-इन हैं और शुरू से ही स्ट्रीम करने के लिए तैयार हैं। + सेटिंग → प्लगइन में कभी भी कम्युनिटी प्लगइन मैनेज करें या नए जोड़ें। + + + रेट्रो-मॉडर्न आर्किटेक्चर + शुद्ध Hi-Fi, कोई बिचौलिया नहीं + देखिए Spotube कैसे आपकी मशीन पर सीधे डीसेंट्रलाइज़्ड प्लेबैक चलाता है। + सीधा पीयर-टू-पीयर • 256 KBPS OPUS + ब्लू आवर + Spotube · प्लेयर डेमो + बीच के पलों के लिए थोड़ा सा संगीत। + 1. डीसेंट्रलाइज़्ड खोज + सर्च सीधे आपके चुने हुए कैटलॉग प्लगइन तक जाती है। Spotube कभी आपकी पसंद की प्रोफ़ाइल नहीं बनाता। + 2. सीधा स्ट्रीम रिज़ॉल्यूशन + ऑडियो सीधे कंटेंट सर्वर से आपकी मशीन तक स्ट्रीम होता है। न कोई प्रॉक्सी, न रिमोट डेटा संग्रह। + 3. लोकल DSP और प्लेबैक + इक्वलाइज़र कंट्रोल और वैकल्पिक ऑफ़लाइन डाउनलोड के साथ लोकल रूप से डिकोड और कैश होता है। + 4. सिंक्ड लिरिक्स और जैम + कराओके लिरिक्स प्लेबैक के समय के साथ चलते हैं, साथ में LAN रिमोट कंट्रोल और ग्रुप जैम। + कोई बिचौलिया सर्वर नहीं। प्लेबैक सीधे आपके डिवाइस पर होता है — शुरू से ही निजी। + + + डिवाइस चुनें + %1$s: “%2$s” + चलाएं + कतार में जोड़ें + अगला चलाएं + यह डिवाइस + इस डिवाइस पर + रिमोट डिवाइस + रिमोट डिवाइस (%1$s) + कनेक्ट किए गए डिवाइस पर + + + कनेक्शन विफल + कनेक्शन टूट गया + भेजना विफल + खोज विफल: %1$s + कनेक्ट नहीं हो सका: %1$s + + + डिवाइस + रीफ़्रेश करें + %1$s:%2$d से कनेक्ट है + डिस्कनेक्ट करें + कनेक्ट हो रहा है... + नेटवर्क पर डिवाइस खोजे जा रहे हैं... + कोई डिवाइस नहीं मिला + पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है। + अज्ञात डिवाइस + पता लगाया जा रहा है... + ID: %1$s... + + + रिमोट कंट्रोल + डिस्कनेक्ट हो गया + कनेक्शन में गड़बड़ी: %1$s + एल्बम कवर + अज्ञात ट्रैक + अज्ञात कलाकार + आगे-पीछे करें + शफ़ल + पिछला + अगला + रिपीट मोड + वॉल्यूम + कतार से हटाएं + और विकल्प + + + कहां चलाएं? + आपका एक जैम सेशन चालू है। चुनें कि यह कलेक्शन कहां चलाना है। + यहां चलाएं + जैम में सुझाएं + + + कोई जैम ब्रोकर सेट नहीं है + ब्रोकर होस्ट खाली है + कनेक्शन विफल + कनेक्शन टूट गया + + + ब्रोकर + anonymous + + + जैम कतार में जोड़ा गया + + %1$d ट्रैक जैम कतार में जोड़ा गया + %1$d ट्रैक जैम कतार में जोड़े गए + + + + रिमोट कंट्रोल अनुरोध + \"%1$s\" इस डिवाइस पर प्लेबैक कंट्रोल करना चाहता है। + मना करें + अनुमति दें + हमेशा अनुमति दें + + + खोज के नतीजे + सभी + ट्रैक + यूज़र + कोई खोज प्रकार उपलब्ध नहीं + खोजने के लिए टाइप करें + ट्रैक खोजने के लिए टाइप करें + कोई नतीजा नहीं मिला + सभी देखें + खोज रहे हैं + ट्रैक लोड हो रहे हैं... + एल्बम लोड हो रहे हैं... + कलाकार लोड हो रहे हैं... + प्लेलिस्ट लोड हो रही हैं... + यूज़र लोड हो रहे हैं... + कोई एल्बम नहीं मिला + कोई कलाकार नहीं मिला + कोई प्लेलिस्ट नहीं मिली + कोई यूज़र नहीं मिला + रिकॉर्ड के ढेर खंगाले जा रहे हैं... + ब्रह्मांड में खोज रहे हैं... + स्पीकर गर्म हो रहे हैं... + विनाइल की धूल झाड़ रहे हैं... + संगीत के देवताओं से सलाह ले रहे हैं... + एंटीना ट्यून कर रहे हैं... + रिकॉर्ड पलट रहे हैं... + खोज की सुविधाएं लोड नहीं हो सकीं + खोज नहीं हो सकी + ट्रैक नहीं खोजे जा सके + एल्बम नहीं खोजे जा सके + कलाकार नहीं खोजे जा सके + प्लेलिस्ट नहीं खोजी जा सकीं + यूज़र नहीं खोजे जा सके + + + ब्राउज़ करें + + + सिंक्ड + सादा + सिंक्ड लिरिक्स उपलब्ध नहीं हैं + सादे लिरिक्स उपलब्ध नहीं हैं + + + %2$s में %1$s + %1$d kbps + %1$s kHz • %2$d ch + कोई मेटाडेटा प्लगइन नहीं चुना गया + + + वापस + फिर से कोशिश करें + डायलॉग बंद करें + लोड हो रहा है... + अज्ञात + अज्ञात गड़बड़ी + + + यूज़र + प्लेलिस्ट + + + हमें सच में खेद है + अभी कुछ ठीक से काम नहीं कर रहा है। आपका अनुभव हमारे लिए मायने रखता है, और हम इसे ठीक करने की पूरी कोशिश कर रहे हैं। कृपया फिर से कोशिश करें या नीचे गड़बड़ी की जानकारी देखें। + गड़बड़ी की जानकारी + क्लिपबोर्ड पर कॉपी करें + पूरा लॉग देखें + पूरा एरर लॉग + + + शफ़ल + शफ़ल करके चलाएं + फ़ॉलो करें + अनफ़ॉलो करें + प्लेलिस्ट एडिट करें + %1$s द्वारा + + + ट्रैक विकल्प + जैम में जोड़ें + रेडियो शुरू करें + अगला चलाएं + अगले पर ले जाएं + कतार से हटाएं + पसंदीदा से हटाएं + पसंदीदा में सेव करें + प्लेलिस्ट में जोड़ें + डाउनलोड करें + शेयर करें + + + मूल क्रम + नाम + कलाकार + एल्बम + अवधि + क्रम से लगाएं + कोई ट्रैक नहीं मिला + फ़िल्टर + एक साथ कार्रवाई + सभी डाउनलोड करें + %1$d डाउनलोड करें + सभी को कतार में जोड़ें + %1$d को कतार में जोड़ें + सभी को अगला चलाएं + %1$d को अगला चलाएं + सभी को प्लेलिस्ट में जोड़ें + %1$d को प्लेलिस्ट में जोड़ें + सभी को जैम में जोड़ें + %1$d को जैम में जोड़ें + +%1$d और + + + लोड करने के लिए कोई URL नहीं + ब्राउज़र इतिहास में पीछे जाएं + ब्राउज़र इतिहास में आगे जाएं + + + ब्लैकलिस्ट फ़िल्टर करें... + ब्लैकलिस्ट में कुछ नहीं है + \'%1$s\' से मेल खाता कोई ब्लैकलिस्ट आइटम नहीं + ट्रैक + ट्रैक + ब्लैकलिस्ट में जोड़ें + ब्लैकलिस्ट से हटाएं + + + ट्रैक का मेटाडेटा नहीं है + स्ट्रीम URL नहीं मिल सका + स्ट्रीम URL नहीं मिल सका। पहले ट्रैक चलाकर देखें। + + + सेव किए गए ट्रैक + आप + + + फ़ॉलो कर रहे हैं + टॉप ट्रैक + टॉप ट्रैक चलाएं + टॉप ट्रैक कतार में जोड़ें + मिलते-जुलते कलाकार + फ़ीचर्ड प्लेलिस्ट + कलाकार की जानकारी लोड नहीं हो सकी + फ़ॉलोअर उपलब्ध नहीं + %1$sK फ़ॉलोअर + %1$sM फ़ॉलोअर + %1$sB फ़ॉलोअर + + %1$d फ़ॉलोअर + %1$d फ़ॉलोअर + + + + पसंद करें + पसंद से हटाएं + No-op ट्रैक का नाम + No-op कलाकार + अज्ञात एल्बम + आगे-पीछे करें + वॉल्यूम + शफ़ल चालू करें + शफ़ल बंद करें + पिछला + अगला + चलाएं या रोकें + रिपीट बंद + एक को रिपीट करें + सभी को रिपीट करें + म्यूट करें + अनम्यूट करें + और विकल्प + प्लेयर विकल्प + प्लेयर छोटा करें + लिरिक्स बड़ा करें + अभी चल रहा है + लिरिक्स + वैकल्पिक सोर्स + वैकल्पिक सोर्स + डाउनलोड करें + डाउनलोड हो गया + डाउनलोड हो रहा है... + पहले से डाउनलोड है + डाउनलोड फिर से शुरू हो रहा है... + %1$s डाउनलोड हो रहा है + प्लेबैक %1$s में रुक जाएगा। + चुने गए मिनटों के बाद प्लेबैक रोकें। + मिनट + 1 से 600 मिनट के बीच चुनें + टाइमर रद्द करें + शुरू करें + प्लेबैक URL अलग स्ट्रीमिंग सेटिंग के साथ तय किया गया था। हाइलाइट की गई स्ट्रीम आपकी मौजूदा पसंद से मेल खाती है; सक्रिय URL अगली स्ट्रीम रिक्वेस्ट पर रीफ़्रेश होगा। + मेल खाता ऑडियो सोर्स + मेल खाता सोर्स लोड हो रहा है… + लोकल कैश फ़ाइल चलाते समय मेल खाते रिमोट सोर्स की ज़रूरत नहीं है। + कोई मेल खाता सोर्स उपलब्ध नहीं है। + इस ट्रैक के लिए कोई मेल खाता ऑडियो सोर्स कैश नहीं है। + स्ट्रीम की जानकारी अभी कैश नहीं है। उसे लोड करने के लिए पहले ट्रैक चलाएं। + ट्रैक की जानकारी लोड नहीं हो सकी। + उपलब्ध स्ट्रीम + यह ट्रैक लोकल कैश से चल रहा है। इस प्लेबैक के लिए स्ट्रीम वेरिएंट इस्तेमाल नहीं होते। + स्ट्रीम लोड हो रही हैं… + इस सोर्स के लिए कोई स्ट्रीम उपलब्ध नहीं है। + अभी चुनी गई स्ट्रीम + आपकी स्ट्रीमिंग फ़ॉर्मैट और क्वालिटी पसंद से मेल खाती है + सक्रिय प्लेबैक URL + लोकल कैश से चल रहा है + मेटाडेटा ट्रैक + नाम + ID + एल्बम + विश्वसनीयता + बाहरी URI + एल्बम ID + एल्बम प्रकार + एल्बम रिलीज़ की तारीख + एल्बम में ट्रैक की संख्या + एल्बम की शैलियां + अवधि + ट्रैक नंबर + डिस्क नंबर + एक्सप्लिसिट + लोकप्रियता + ISRC + थंबनेल URL + + + साइडबार दिखाएं/छिपाएं + + + वैकल्पिक सोर्स + अभी कोई ट्रैक नहीं चल रहा + वैकल्पिक सोर्स खोजे जा रहे हैं... + कोई वैकल्पिक सोर्स नहीं मिला + विश्वसनीयता: %1$d% + सक्रिय सोर्स + + + निकालें + उन्हें सेशन से हटाएं + निकालें और दोबारा जुड़ने से रोकें + सुझाव हटाएं + उनके कतार में जोड़े गए सभी ट्रैक हटाएं + क्रम बदलें + कतार से हटाएं + %1$s • %2$s ने जोड़ा + + + अज्ञात + प्लेलिस्ट लोड हो रही है... + ट्रैक जोड़ें + प्लेलिस्ट में ट्रैक जोड़ें + ट्रैक खोजें... + खोजें + कोई ट्रैक नहीं मिला + जोड़ने के लिए ट्रैक खोजें + हो गया + जोड़ें + जोड़ा गया + + + एल्बम लोड हो रहा है... + + + अपनी प्लेलिस्ट में जोड़ें + खोजें + अपनी प्लेलिस्ट खोजें... + आपकी प्लेलिस्ट लोड हो रही हैं... + अभी तक आपकी कोई प्लेलिस्ट नहीं है।\nट्रैक जोड़ने के लिए एक प्लेलिस्ट बनाएं। + प्लेलिस्ट बनाएं + प्लेलिस्ट एडिट करें + बनाएं + नाम + प्लेलिस्ट का नाम + विवरण + विवरण (वैकल्पिक) + सार्वजनिक + कोई भी यह प्लेलिस्ट देख सकता है + साझा + दूसरे लोग इस प्लेलिस्ट में ट्रैक जोड़ सकते हैं + कवर इमेज + प्लेलिस्ट कवर + कोई इमेज नहीं + इमेज बदलें + इमेज चुनें + + + इंस्टॉल करें + बदलें + zip फ़ाइल में plugin.json नहीं मिला + zip फ़ाइल में manifest.zipline.json नहीं मिला + plugin.json का फ़ॉर्मैट अमान्य है: %1$s + प्लगइन पढ़ा नहीं जा सका: %1$s + प्लगइन इंस्टॉल नहीं हो सका: %1$s + %1$s का नया वर्शन उपलब्ध है। इंस्टॉल करने से पहले अपडेट की पुष्टि करें। + %1$s पहले से इंस्टॉल है। इंस्टॉल करने से पहले उसे बदलने की पुष्टि करें। + बिल्ट-इन प्लगइन पहले से शामिल हैं और दोबारा नहीं जोड़े जा सकते। + बिल्ट-इन प्लगइन हटाए नहीं जा सकते। + %1$s ने अमान्य apiVersion (%2$s) बताया है। %3$s के साथ संगत सिमैंटिक वर्शनिंग अपेक्षित है। + Spotube प्लगइन API वर्शन %1$s अमान्य है। + %1$s प्लगइन API %2$s के लिए बना है, लेकिन Spotube ब्रेकिंग प्लगइन API %3$s सपोर्ट करता है। उसी ब्रेकिंग API वर्शन के लिए बना प्लगइन इंस्टॉल करें। + + + प्लगइन API संगत नहीं है + प्लगइन इंस्टॉल करें? + %1$s आपके इंस्टॉल किए गए प्लगइन में जोड़ा जाएगा। + प्लगइन अपडेट करें? + एक पुराना वर्शन (%1$s) इंस्टॉल है। %2$s पर अपडेट करें? + इंस्टॉल किया गया प्लगइन बदलें? + %1$s अमान्य सिमैंटिक वर्शन (%2$s) इस्तेमाल करता है और अपडेट के रूप में इसकी तुलना नहीं हो सकती। फिर भी इंस्टॉल किया गया प्लगइन बदलें? + इंस्टॉल किए गए वर्शन (%1$s) की सिमैंटिक वर्शनिंग से तुलना नहीं हो सकती। इसे %2$s से बदलें? + वर्शन %1$s पहले से इंस्टॉल है। मौजूदा प्लगइन को दी गई कॉपी से बदलें? + इंस्टॉल किया गया वर्शन %1$s, दिए गए वर्शन %2$s से नया है। फिर भी बदलें? + इंस्टॉल किए गए प्लगइन को दी गई कॉपी से बदलें? + + + %1$s: + वर्शन + API वर्शन + लेखक + लाइसेंस + क्षमताएं + सुविधाएं + रिपॉज़िटरी + संपर्क + बग रिपोर्ट करें + %1$s को सहयोग दें + + + आधिकारिक + सत्यापित + GitHub स्टार + GitHub रिपॉज़िटरी URL + + + कोई रिलीज़ नहीं मिली + रिलीज़ चुनें + एक रिलीज़ चुनें + प्री-रिलीज़ + रिलीज़ नोट्स + + + होम + खोजें + डिवाइस पर + कनेक्ट + डिवाइस + + + विंडो छिपाएं + विंडो दिखाएं + अगला ट्रैक + पिछला ट्रैक + शफ़ल + रिपीट + रिपीट: बंद + रिपीट: एक + रिपीट: सभी + वॉल्यूम: %1$d% + वॉल्यूम + + वॉल्यूम - + म्यूट करें + अनम्यूट करें + ट्रैक पसंद करें + ट्रैक को पसंद से हटाएं + बाहर निकलें + + + छोटा करें + बड़ा करें + पहले जैसा करें + बंद करें + + + प्लेबैक सेवा + प्लेबैक + मीडिया प्लेबैक कंट्रोल + + + ब्राउज़ करें + अभी चल रहा है + फ़ीचर्ड + सेव किए गए ट्रैक + टॉप ट्रैक + diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml new file mode 100644 index 00000000..fbc02ba5 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -0,0 +1,932 @@ + + + + Antrean + Kosongkan Antrean + Filter antrean... + Antrean kosong + + Cari lagu, artis, album... + Hapus semua riwayat + Cari... + + + Koleksi Kamu + Koleksi + Playlist + Album + Artis + Lagu Lokal + Unduhan + Cari playlist tersimpan… + Cari album tersimpan… + Cari artis tersimpan… + Cari folder atau lagu… + Filter koleksi + Tutup filter + Buat playlist + Muat ulang lagu lokal + Beralih ke tampilan petak + Beralih ke tampilan daftar + Belum ada playlist tersimpan + Tidak ada playlist yang cocok dengan “%1$s” + Belum ada album tersimpan + Tidak ada album yang cocok dengan “%1$s” + Belum ada artis tersimpan + Tidak ada artis yang cocok dengan “%1$s” + + + Folder + Folder + %1$s • %2$s + + %1$d lagu + + + %1$d folder + + Kembali ke folder + Putar folder + Akses audio diperlukan + Izinkan akses audio untuk menjelajahi musik di perangkatmu. + Beri akses + Tidak ada folder lokal + Tidak ada lagu di folder ini + Tidak ada folder atau lagu yang cocok + Gagal memuat ulang media lokal + + + %1$d aktif • %2$d selesai + Hapus yang selesai + Belum ada unduhan + Mengunduh %1$d% + Dalam antrean + Selesai + Dibatalkan + Gagal + Gagal: %1$s + Batalkan unduhan + Coba unduh lagi + Hapus unduhan + + + Artis + Diblokir + Lagu yang Disukai + Lagu tersimpanmu + Artis tidak dikenal + Judul lagu + Nama artis + + Dipilih + + Pengaturan + + Plugin + Bahasa dan Wilayah + Tampilan + Pemutaran + Unduhan + Cache + Desktop + Pembaruan + Dukungan + Tentang + + Tutup + Simpan + Batal + + Bahasa + Bahasa antarmuka aplikasi. Saat ini: %1$s + Bawaan sistem + Negara + Negara untuk konten dan ketersediaan. Saat ini: %1$s + %1$s (%2$s) + + Tema + Pilih cara Spotube menyesuaikan dengan tampilan sistem. Saat ini: %1$s + Terang + Gelap + Sistem + + Permukaan + Material latar belakang aplikasi. Saat ini: %1$s + Air + Es + Krom + Akrilik + Kaca + + Warna aksen + Palet saat ini: %1$s + Warna aksen + Pilih palet aksen dan pratinjau dalam tema terang maupun gelap. + Terang + Gelap + Pratinjau + Tombol + Green Goblin + Ungu Elektrik + Sian Samudra + Jingga Senja + Taman Mawar + Biru Tengah Malam + Batu Metalik + + Tampilan & nuansa + Material permukaan dan aksen. Saat ini: %1$s · %2$s + Tampilan & nuansa + Pilih material permukaan, lalu aksen yang serasi. Setiap opsi adalah kombinasi pilihan. + Permukaan + Aksen + Sekunder + + Palet + Palet aksen Millennium. Saat ini: %1$s + Palet + Pilih palet aksen Millennium dan pratinjau dalam mode terang dan gelap. + Palet + Palet dan aksen Millennium. Saat ini: %1$s + Biru + Jingga + Hijau Limau + + Ganti folder unduhan + Folder saat ini: %1$s + Folder saat ini: folder musik bawaan + Biarkan kosong untuk memakai folder musik bawaan. + C:\Music\Spotube + Folder media lokal + Folder kustom untuk dipindai di desktop. Ditambahkan: %1$d + Belum ada folder kustom + Folder kustom untuk koleksi musik lokalmu. + Kelola + Tambah folder + Hapus folder + Belum ada folder kustom yang ditambahkan. + Hapus %1$s dari daftar pindai + Folder unduhan dan cache musik disertakan secara otomatis. + Folder unduhanmu disertakan secara otomatis. + Folder cache musik disertakan secara otomatis. + Menyimpan… + Gagal menyimpan folder. Perubahanmu masih ada; coba lagi. + + %1$d folder + + Format unduhan musik + Codec dan kontainer pilihan untuk unduhan. Saat ini: %1$s + Kualitas unduhan musik + + Format streaming musik + Codec dan kontainer pilihan untuk streaming musik. Saat ini: %1$s + Kualitas streaming musik + Saat ini: %1$s + Aktifkan cache musik + Simpan audio yang di-streaming secara lokal agar pemutaran ulang lebih cepat. + Folder cache + Saat ini: %1$s + Saat ini: cache aplikasi bawaan + Batas ukuran cache + Tanpa batas + Batas saat ini: %1$d MB + Atur ukuran cache maksimum dalam MB. Masukkan 0 atau kosongkan untuk tanpa batas. + 500 + Ukuran harus 0 atau bilangan positif. + Aktifkan pemutaran tanpa henti + Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis. + Aktifkan Connect + Tampilkan kontrol pemutaran jarak jauh melalui fitur Spotube Connect. + Port server proxy pemutaran + Izinkan kontrol jarak jauh + Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung. + Nama perangkat kontrol jarak jauh + Nama yang dilihat perangkat lain saat menemukan perangkat ini. Saat ini: %1$s + Pilih nama yang mudah dikenali untuk ditampilkan ke perangkat lain di jaringanmu saat mereka menemukan Spotube ini. + mis. Spotube Ruang Tamu + nama host perangkat + Port yang dipakai server proxy pemutaran. Saat ini: %1$d + Pilih port antara 1 dan 65535. + 14769 + Masukkan bilangan bulat yang valid. + Port harus antara 1 dan 65535. + + Daftar blokir + Kelola lagu dan artis yang diblokir + + Minimalkan ke tray + Biarkan Spotube tetap berjalan di system tray saat jendela diminimalkan. + Discord Rich Presence + Tampilkan status pemutaran saat ini di Discord pada desktop. + + Periksa pembaruan otomatis + Cari rilis Spotube baru di latar belakang. + + Kelola Plugin + Kelola plugin kamu + Plugin %1$s Bawaan + Metadata + Audio + Lirik + Scrobble + Belum ada plugin dipilih + Ubah + Pilih + Dipilih + Hapus pilihan + Tidak ada plugin tersedia + Plugin %1$s + Hapus + + Konfigurasi + Pengelola Plugin + Masukkan URL + URL harus diawali http:// atau https:// + Gagal mengunduh plugin + Belum ada plugin terpasang + Tempel URL unduhan atau pilih file di atas. + + %1$d plugin terpasang + + Pasang Plugin + https://example.com/plugin.smplug + Unduh + Pasang dari file (.smplug) + Aktif + v%1$s + Bawaan + Hapus plugin + Masuk + Keluar + Unduh dari URL + Pasang dari file + Pasang + + • %1$s + Izin yang diminta + Plugin ini tidak memerlukan izin khusus. + Terpasang vs diberikan + Terpasang + Diberikan + API %1$s → %2$s + Penyimpanan Persisten + Membaca dan menulis data ke disk + Permintaan Jaringan + Mengirim dan menerima data melalui internet + WebView + Menampilkan konten web di dalam aplikasi + Jam Grup + Broker MQTT + Host broker + %1$s:%2$d + Port broker + Gunakan TLS + Nama pengguna (opsional) + Kata sandi (opsional) + Prefiks ID klien + Dipakai untuk mengidentifikasi Spotube ini + Enkripsi koneksi broker + Periksa pengaturan broker saat ini sebelum memulai ruang. + Uji koneksi + Menguji… + Terhubung dalam %1$d ms + Gagal: %1$s + Broker sementara — konfigurasikan milikmu sendiri untuk self-host + Koneksi + Kredensial + Identitas + Diagnostik + + + Jam Grup + Buat + Gabung + Buat ruang, lalu bagikan kodenya ke teman-temanmu. + Buat ruang + Gabung ke ruang + Menghubungkan… + Kode ruang + ABC123 + Broker: %1$s + Atur broker di Pengaturan untuk membuat atau bergabung ke ruang. + Pengaturan broker + Terhubung + Menghubungkan ulang… + Salin kode + Disalin! + Bagikan + Peserta (%1$d) + Host + Pendengar + Menunggu pendengar… + Keluarkan + Blokir + Kelola + Tinggalkan ruang + Gagal terhubung + Tutup + Gagal membuat sesi Jam + Gagal bergabung ke sesi Jam + Masukkan kode ruang enam karakter berisi huruf dan angka. + Gabung ke Jam Spotube-ku dengan kode: %1$s + + Buka album + Timer tidur + Detail lagu + Putar + Jeda + Tidak ada yang diputar + Tambahkan ke antrean + + Abaikan + Perbarui + Spotube %1$s telah tersedia + Lihat yang baru di rilis ini pada halaman unduhan Spotube. + %1$s %2$s telah tersedia + Terpasang saat ini: %1$s + Lihat yang baru di pembaruan ini pada halaman plugin. + + Lewati + Berikutnya + Kembali + Mulai + Spotube + Musik, tanpa ikatan. + Pemutar open source untuk dunia musik.\nBebas kamu jelajahi. Bebas kamu jadikan milikmu. + Bantu proyek ini tetap hidup. + Spotube dibuat oleh orang-orang, bukan bisnis langganan. Jika Spotube menjadi bagian dari keseharianmu, sedikit dukungan membantu kami merawatnya sebagaimana mestinya.\n\nTerima kasih telah memberi ruang bagi perangkat lunak independen. + Mulailah dengan plugin katalog untuk menemukan lagu dan playlist. Pilih plugin audio untuk memutarnya. Lirik dan riwayat dengar bersifat opsional. Temukan plugin di bawah, tinjau izinnya, pasang, lalu pilih di sini. + Temukan plugin + Sumbermu + Coba lagi + Plugin lainnya + Tidak ada plugin baru. Kamu juga bisa memasang plugin dari tautan atau file .smplug di atas. + Penemuan plugin sedang tidak tersedia. Coba lagi, atau pasang dari tautan atau file. + Selamat datang + Dukungan + Plugin + Panduan + + Cara menggunakan Spotube + Empat langkah singkat dan kamu siap mendengarkan. + Masuk + Hubungkan akun Spotify-mu, atau pilih plugin katalogmu sendiri. + Cari dan putar + Temukan lagu, album, atau artis apa pun, lalu tekan putar. + Dengarkan offline + Unduh lagu agar tetap bisa diputar tanpa koneksi. + Ikuti liriknya + Buka lirik tersinkron dari pemutar saat lagu diputar. + + + Musikmu. Sumbermu. Aturanmu. + Pemutar musik gratis dan open source yang dibuat oleh komunitas pencinta musik. + Open source + Tanpa iklan + Tanpa telemetri + Berbasis plugin + Ayo Mulai + + + Pendukung membuat kami terus berjalan + Dibuat dengan semangat. Dihidupkan olehmu. + Spotube 100% gratis, bebas, dan open source — tanpa iklan, tanpa langganan, tanpa pelacakan. Setiap fitur ada karena orang sepertimu memilih untuk membantu kode terus berkembang. + Patreon + Sponsor Bulanan + Dukung pengembangan dan infrastruktur secara rutin + Buy Me a Coffee + Kopi Sekali Saja + Beri semangat sesi ngoding larut malam dengan tip singkat + Open Collective + Dana Komunitas + Dukungan finansial yang transparan dan dikelola komunitas + Terima kasih dari lubuk hati kami karena telah percaya pada musik terbuka. + Dukungan sepenuhnya opsional — Spotube tetap gratis apa pun pilihanmu. + Sponsori + Buka %1$s + + + Dukung Spotube + Dukung Spotube + Spotube gratis, bebas, dan open source — tanpa iklan, tanpa langganan, tanpa pelacakan. Jika Spotube membuatmu senang, pertimbangkan untuk mendukung proyek ini. + Kamu bisa mematikan pengingat ini di Pengaturan → Dukungan. + Nanti saja + Pengingat dukungan + Tampilkan pengingat ringan untuk mendukung proyek setiap 7 hari. + + + Arsitektur Modular + Pilih dari mana musikmu berasal. + Spotube memisahkan penemuan katalog, streaming audio, dan lirik tersinkron. Atur mesinmu di bawah atau ganti kapan saja. + Mesin Metadata + Mendukung penemuan musik, pencarian, daftar lagu, dan playlist. + Mesin Audio + Mengambil dan mendekode stream audio yang diputar. + Mesin Lirik + Menampilkan lirik karaoke tersinkron secara real-time. + Mesin Scrobble + Mencatat riwayat dengar ke Last.fm atau ListenBrainz. + Aktif + Bawaan + Siap + Opsional + Pilih mesin + Mesin Terpasang + Jadikan bawaan + NewPipe YouTube dan LRCLib Lyrics sudah bawaan dan siap diputar langsung. + Kelola atau tambahkan plugin komunitas kapan saja di Pengaturan → Plugin. + + + Arsitektur Retro-Modern + Hi-Fi murni, tanpa perantara + Begini cara Spotube mengatur pemutaran terdesentralisasi langsung di perangkatmu. + PEER-TO-PEER LANGSUNG • 256 KBPS OPUS + Blue hour + Spotube · demonstrasi pemutar + Sedikit musik untuk momen-momen di antaranya. + 1. Penemuan Terdesentralisasi + Pencarian langsung dikirim ke plugin katalog pilihanmu. Spotube tidak pernah memprofilkan selera musikmu. + 2. Resolusi Stream Langsung + Audio di-streaming langsung dari server konten ke perangkatmu. Tanpa lompatan proxy atau pengumpulan data jarak jauh. + 3. DSP & Pemutaran Lokal + Didekode dan di-cache secara lokal dengan kontrol equalizer dan unduhan offline opsional. + 4. Lirik Tersinkron & Jam + Lirik karaoke mengikuti waktu pemutaran, dengan kontrol jarak jauh via LAN dan Jam Grup. + Tanpa server perantara. Pemutaran terjadi langsung di perangkatmu — privat sejak awal. + + + Pilih perangkat + %1$s: “%2$s” + Putar + Tambahkan ke antrean + Putar berikutnya + Perangkat Ini + Di perangkat ini + Perangkat Jarak Jauh + Perangkat Jarak Jauh (%1$s) + Di perangkat yang terhubung + + + Koneksi gagal + Koneksi terputus + Gagal mengirim + Pencarian gagal: %1$s + Gagal terhubung: %1$s + + + Perangkat + Muat ulang + Terhubung ke %1$s:%2$d + Putuskan + Menghubungkan... + Mencari perangkat di jaringan... + Tidak ada perangkat ditemukan + Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan. + Perangkat Tidak Dikenal + Menyelesaikan... + ID: %1$s... + + + Kontrol Jarak Jauh + Terputus + Kesalahan koneksi: %1$s + Sampul album + Lagu Tidak Dikenal + Artis Tidak Dikenal + Cari posisi + Acak + Sebelumnya + Berikutnya + Mode ulang + Volume + Hapus dari antrean + Opsi lainnya + + + Putar di Mana? + Kamu sedang dalam sesi Jam. Pilih tempat untuk memutar koleksi ini. + Putar di sini + Sarankan ke Jam + + + Belum ada broker Jam yang dikonfigurasi + Host broker kosong + Koneksi gagal + Koneksi terputus + + + Broker + anonim + + + Ditambahkan ke antrean Jam + + %1$d ditambahkan ke antrean Jam + + + + Permintaan Kontrol Jarak Jauh + \"%1$s\" ingin mengontrol pemutaran di perangkat ini. + Tolak + Izinkan + Selalu Izinkan + + + Hasil pencarian + Semua + Lagu + Pengguna + Tidak ada jenis pencarian tersedia + Mulai mengetik untuk mencari + Mulai mengetik untuk mencari lagu + Tidak ada hasil + Lihat semua + Mencari + Memuat lagu... + Memuat album... + Memuat artis... + Memuat playlist... + Memuat pengguna... + Tidak ada album ditemukan + Tidak ada artis ditemukan + Tidak ada playlist ditemukan + Tidak ada pengguna ditemukan + Membongkar tumpukan piringan... + Menjelajahi alam semesta... + Memanaskan speaker... + Membersihkan debu piringan hitam... + Bertanya pada dewa musik... + Menyetel antena... + Membolak-balik koleksi piringan... + Gagal memuat kemampuan pencarian + Gagal mencari + Gagal mencari lagu + Gagal mencari album + Gagal mencari artis + Gagal mencari playlist + Gagal mencari pengguna + + + Jelajahi + + + Tersinkron + Biasa + Lirik tersinkron tidak tersedia + Lirik biasa tidak tersedia + + + %1$s dalam %2$s + %1$d kbps + %1$s kHz • %2$d ch + Belum ada plugin metadata dipilih + + + Kembali + Coba lagi + Tutup dialog + Memuat... + Tidak diketahui + Kesalahan tidak diketahui + + + Pengguna + Playlist + + + Kami Mohon Maaf + Ada yang tidak berfungsi saat ini. Pengalamanmu penting bagi kami, dan kami berusaha sebaik mungkin untuk memperbaikinya. Coba lagi atau periksa detail kesalahan di bawah. + Detail Kesalahan + Salin ke papan klip + Lihat log lengkap + Log Kesalahan Lengkap + + + Acak + Putar acak + Ikuti + Berhenti mengikuti + Edit playlist + Oleh %1$s + + + Opsi lagu + Tambahkan ke Jam + Mulai radio + Putar berikutnya + Pindahkan ke berikutnya + Hapus dari antrean + Hapus dari favorit + Simpan sebagai favorit + Tambahkan ke playlist + Unduh + Bagikan + + + Asli + Judul + Artis + Album + Durasi + Urutkan + Tidak ada lagu ditemukan + Filter + Tindakan massal + Unduh Semua + Unduh %1$d + Tambahkan Semua ke Antrean + Tambahkan %1$d ke Antrean + Putar Semua Berikutnya + Putar %1$d Berikutnya + Tambahkan Semua ke Playlist + Tambahkan %1$d ke Playlist + Tambahkan Semua ke Jam + Tambahkan %1$d ke Jam + +%1$d lainnya + + + Tidak ada URL untuk dimuat + Kembali di riwayat browser + Maju di riwayat browser + + + Filter daftar blokir... + Tidak ada item yang diblokir + Tidak ada item diblokir yang cocok dengan \'%1$s\' + Lagu + Lagu + Tambahkan ke daftar blokir + Hapus dari daftar blokir + + + Tidak ada metadata lagu + Tidak dapat menemukan URL stream + Tidak dapat menemukan URL stream. Coba putar lagunya terlebih dahulu. + + + Lagu Tersimpan + Kamu + + + Mengikuti + Lagu Teratas + Putar lagu teratas + Tambahkan lagu teratas ke antrean + Artis Terkait + Playlist Unggulan + Gagal memuat ringkasan artis + Jumlah pengikut tidak tersedia + %1$s rb pengikut + %1$s jt pengikut + %1$s M pengikut + + %1$d pengikut + + + + Suka + Batal suka + Judul lagu kosong + Artis kosong + Album Tidak Dikenal + Cari posisi + Volume + Aktifkan acak + Nonaktifkan acak + Sebelumnya + Berikutnya + Putar atau jeda + Mode ulang nonaktif + Ulang satu lagu + Ulang semua + Bisukan + Bunyikan + Opsi lainnya + Opsi pemutar + Ciutkan pemutar + Perluas lirik + Sedang Diputar + Lirik + Sumber Alternatif + Sumber alternatif + Unduh + Diunduh + Mengunduh... + Sudah diunduh + Mencoba mengunduh lagi... + Mengunduh %1$s + Pemutaran akan dijeda dalam %1$s. + Jeda pemutaran setelah jumlah menit yang dipilih. + Menit + Pilih antara 1 dan 600 menit + Batalkan timer + Mulai + URL pemutaran ditentukan dengan pengaturan streaming yang berbeda. Stream yang disorot sesuai dengan preferensimu saat ini; URL aktif akan diperbarui pada permintaan stream berikutnya. + Sumber audio yang cocok + Memuat sumber yang cocok… + Sumber jarak jauh yang cocok tidak diperlukan saat memutar file cache lokal. + Tidak ada sumber yang cocok. + Tidak ada sumber audio cocok yang di-cache untuk lagu ini. + Detail stream belum di-cache. Putar lagunya terlebih dahulu untuk memuatnya. + Gagal memuat detail lagu. + Stream yang tersedia + Lagu ini diputar dari cache lokal. Varian stream tidak digunakan untuk pemutaran ini. + Memuat stream… + Tidak ada stream tersedia untuk sumber ini. + Stream yang sedang dipilih + Sesuai dengan preferensi format & kualitas streaming-mu + URL pemutaran aktif + Diputar dari cache lokal + Metadata lagu + Judul + ID + Album + Tingkat kecocokan + URI Eksternal + ID Album + Jenis album + Tanggal rilis album + Jumlah lagu album + Genre album + Durasi + Nomor lagu + Nomor disk + Eksplisit + Popularitas + ISRC + URL thumbnail + + + Tampilkan/Sembunyikan Sidebar + + + Sumber Alternatif + Tidak ada lagu yang sedang diputar + Mencari sumber alternatif... + Tidak ada sumber alternatif ditemukan + Kecocokan: %1$d% + Sumber aktif + + + Keluarkan + Keluarkan mereka dari sesi + Keluarkan dan cegah mereka bergabung kembali + Hapus saran + Hapus semua lagu yang mereka tambahkan ke antrean + Ubah urutan + Hapus dari antrean + %1$s • Ditambahkan oleh %2$s + + + Tidak diketahui + Memuat playlist... + Tambah Lagu + Tambahkan Lagu ke Playlist + Cari lagu... + Cari + Tidak ada lagu ditemukan + Cari lagu untuk ditambahkan + Selesai + Tambah + Ditambahkan + + + Memuat album... + + + Tambahkan ke Playlist Kamu + Cari + Cari playlist kamu... + Memuat playlist kamu... + Kamu belum punya playlist.\nBuat satu untuk menambahkan lagu. + Buat Playlist + Edit Playlist + Buat + Nama + Nama playlist + Deskripsi + Deskripsi (opsional) + Publik + Siapa saja dapat melihat playlist ini + Kolaboratif + Orang lain dapat menambahkan lagu ke playlist ini + Gambar Sampul + Sampul playlist + Tidak ada gambar + Ganti Gambar + Pilih Gambar + + + Pasang + Ganti + plugin.json tidak ditemukan di file zip + manifest.zipline.json tidak ditemukan di file zip + Format plugin.json tidak valid: %1$s + Gagal membaca plugin: %1$s + Gagal memasang plugin: %1$s + Versi lebih baru dari %1$s tersedia. Konfirmasi pembaruan sebelum memasang. + %1$s sudah terpasang. Konfirmasi penggantian sebelum memasang. + Plugin bawaan sudah disertakan dan tidak dapat ditambahkan lagi. + Plugin bawaan tidak dapat dihapus. + %1$s mendeklarasikan apiVersion yang tidak valid (%2$s). Diharapkan semantic versioning yang kompatibel dengan %3$s. + Versi API plugin Spotube %1$s tidak valid. + %1$s menargetkan API plugin %2$s, tetapi Spotube mendukung API plugin breaking %3$s. Pasang plugin yang dibuat untuk versi API breaking yang sama. + + + API plugin tidak kompatibel + Pasang plugin? + %1$s akan ditambahkan ke plugin terpasang. + Perbarui plugin? + Versi lama terpasang (%1$s). Perbarui ke %2$s? + Ganti plugin terpasang? + %1$s menggunakan versi semantik yang tidak valid (%2$s) dan tidak dapat dibandingkan sebagai pembaruan. Tetap ganti plugin terpasang? + Versi terpasang (%1$s) tidak dapat dibandingkan menggunakan semantic versioning. Ganti dengan %2$s? + Versi %1$s sudah terpasang. Ganti plugin yang ada dengan salinan yang diberikan? + Versi terpasang %1$s lebih baru daripada versi yang diberikan %2$s. Tetap ganti? + Ganti plugin terpasang dengan salinan yang diberikan? + + + %1$s: + Versi + Versi API + Pembuat + Lisensi + Kapabilitas + Kemampuan + Repositori + Kontak + Laporkan Bug + Dukung %1$s + + + Resmi + Terverifikasi + Bintang GitHub + URL repositori GitHub + + + Tidak ada rilis ditemukan + Pilih Rilis + Pilih rilis + Pra-rilis + Catatan Rilis + + + Beranda + Cari + Di Perangkat + Connect + Perangkat + + + Sembunyikan Jendela + Tampilkan Jendela + Lagu Berikutnya + Lagu Sebelumnya + Acak + Ulang + Ulang: MATI + Ulang: SATU + Ulang: SEMUA + Volume: %1$d% + Volume + + Volume - + Bisukan + Bunyikan + Sukai Lagu + Batal Sukai Lagu + Keluar + + + Minimalkan + Maksimalkan + Pulihkan + Tutup + + + Layanan pemutaran + Pemutaran + Kontrol pemutaran media + + + Jelajahi + Sedang Diputar + Unggulan + Lagu Tersimpan + Lagu Teratas + diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml new file mode 100644 index 00000000..4a3475b6 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -0,0 +1,944 @@ + + + + Coda + Svuota coda + Filtra coda... + La coda è vuota + + Cerca brani, artisti, album... + Cancella tutta la cronologia + Cerca... + + + La tua libreria + Libreria + Playlist + Album + Artisti + Brani locali + Download + Cerca nelle playlist salvate… + Cerca negli album salvati… + Cerca negli artisti salvati… + Cerca cartelle o brani… + Filtra libreria + Chiudi filtro + Crea playlist + Aggiorna brani locali + Passa alla vista a griglia + Passa alla vista a elenco + Nessuna playlist salvata + Nessuna playlist corrisponde a “%1$s” + Nessun album salvato + Nessun album corrisponde a “%1$s” + Nessun artista salvato + Nessun artista corrisponde a “%1$s” + + + Cartelle + Cartella + %1$s • %2$s + + %1$d brano + %1$d brani + %1$d brani + + + %1$d cartella + %1$d cartelle + %1$d cartelle + + Torna alle cartelle + Riproduci cartella + Accesso all\'audio necessario + Consenti l\'accesso all\'audio per sfogliare la musica sul tuo dispositivo. + Consenti accesso + Nessuna cartella locale trovata + Nessun brano in questa cartella + Nessuna cartella o brano corrispondente + Impossibile aggiornare i contenuti locali + + + %1$d attivi • %2$d completati + Rimuovi completati + Ancora nessun download + Download in corso %1$d% + In coda + Completato + Annullato + Non riuscito + Non riuscito: %1$s + Annulla download + Riprova download + Rimuovi download + + + Artista + In blacklist + Brani che ti piacciono + I tuoi brani salvati + Artista sconosciuto + Titolo del brano + Nome dell\'artista + + Selezionato + + Impostazioni + + Plugin + Lingua e area geografica + Aspetto + Riproduzione + Download + Cache + Desktop + Aggiornamenti + Supporto + Informazioni + + Chiudi + Salva + Annulla + + Lingua + Lingua dell\'interfaccia dell\'app. Attuale: %1$s + Predefinita di sistema + Paese + Paese per contenuti e disponibilità. Attuale: %1$s + %1$s (%2$s) + + Tema + Scegli come Spotube si adatta all\'aspetto del sistema. Attuale: %1$s + Chiaro + Scuro + Sistema + + Superficie + Materiale di sfondo dell\'app. Attuale: %1$s + Acqua + Ghiaccio + Cromo + Acrilico + Vetro + + Colore di accento + Palette attuale: %1$s + Colore di accento + Scegli una palette di accento e visualizzane l\'anteprima nei temi chiaro e scuro. + Chiaro + Scuro + Anteprima + Pulsante + Verde Goblin + Viola elettrico + Ciano oceano + Arancio tramonto + Giardino di rose + Blu notte + Ardesia metallica + + Aspetto e stile + Materiale della superficie e accento. Attuale: %1$s · %2$s + Aspetto e stile + Scegli un materiale per la superficie, poi un accento abbinato. Ogni opzione è una combinazione curata. + Superficie + Accento + Secondario + + Palette + Palette di accento Millennium. Attuale: %1$s + Palette + Scegli una palette di accento Millennium e visualizzane l\'anteprima in chiaro e scuro. + Palette + Palette e accento Millennium. Attuale: %1$s + Blu + Arancione + Lime + + Cartella di download personalizzata + Cartella attuale: %1$s + Cartella attuale: cartella Musica predefinita + Lascia vuoto per usare la cartella Musica predefinita. + C:\Music\Spotube + Cartelle multimediali locali + Cartelle personalizzate da analizzare sul desktop. Aggiunte: %1$d + Nessuna cartella personalizzata aggiunta + Cartelle personalizzate per la tua libreria musicale locale. + Gestisci + Aggiungi cartella + Rimuovi cartella + Nessuna cartella personalizzata aggiunta. + Rimuovi %1$s dall\'elenco di analisi + Le cartelle di download e della cache musicale sono incluse automaticamente. + La tua cartella di download è inclusa automaticamente. + La cartella della cache musicale è inclusa automaticamente. + Salvataggio… + Impossibile salvare le cartelle. Le modifiche sono ancora qui; riprova. + + %1$d cartella + %1$d cartelle + %1$d cartelle + + Formato musica scaricata + Codec e contenitore preferiti per i download. Attuale: %1$s + Qualità musica scaricata + + Formato musica in streaming + Codec e contenitore preferiti per lo streaming musicale. Attuale: %1$s + Qualità musica in streaming + Attuale: %1$s + Attiva cache musicale + Salva l\'audio in streaming in locale per velocizzare gli ascolti successivi. + Cartella cache + Attuale: %1$s + Attuale: cache predefinita dell\'app + Limite dimensione cache + Nessun limite + Limite attuale: %1$d MB + Imposta la dimensione massima della cache in MB. Inserisci 0 o lascia vuoto per nessun limite. + 500 + La dimensione deve essere 0 o un numero positivo. + Attiva riproduzione continua + Continua la riproduzione con brani consigliati quando la coda finisce. + Attiva Connect + Rendi disponibili i controlli di riproduzione remota tramite la funzione Spotube Connect. + Porta del server proxy di riproduzione + Consenti controllo remoto + Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono. + Nome del dispositivo per il controllo remoto + Il nome che gli altri dispositivi vedono quando trovano questo. Attuale: %1$s + Scegli un nome riconoscibile da mostrare agli altri dispositivi della tua rete quando trovano questa istanza di Spotube. + es. Spotube del soggiorno + nome host del dispositivo + Porta usata dal server proxy di riproduzione. Attuale: %1$d + Scegli una porta tra 1 e 65535. + 14769 + Inserisci un numero intero valido. + La porta deve essere compresa tra 1 e 65535. + + Blacklist + Gestisci brani e artisti in blacklist + + Riduci nell\'area di notifica + Mantieni Spotube in esecuzione nell\'area di notifica quando la finestra è ridotta a icona. + Rich Presence di Discord + Mostra lo stato di riproduzione attuale su Discord dal desktop. + + Controlla automaticamente gli aggiornamenti + Cerca nuove versioni di Spotube in background. + + Gestisci plugin + Gestisci i tuoi plugin + Plugin %1$s predefinito + Metadati + Audio + Testi + Scrobbling + Nessun plugin selezionato + Cambia + Seleziona + Selezionato + Cancella selezione + Nessun plugin disponibile + Plugin %1$s + Cancella + + Configura + Gestione plugin + Inserisci un URL + L\'URL deve iniziare con http:// o https:// + Impossibile scaricare il plugin + Nessun plugin installato + Incolla un URL di download o scegli un file qui sopra. + + %1$d plugin installato + %1$d di plugin installati + %1$d plugin installati + + Installa un plugin + https://example.com/plugin.smplug + Scarica + Installa da file (.smplug) + Attivo + v%1$s + Integrato + Rimuovi plugin + Accedi + Esci + Scarica da URL + Installa da file + Installa + + • %1$s + Autorizzazioni richieste + Questo plugin non richiede autorizzazioni speciali. + Installato e fornito + Installato + Fornito + API %1$s → %2$s + Archiviazione permanente + Leggere e scrivere dati su disco + Richieste di rete + Inviare e ricevere dati tramite Internet + WebView + Mostrare contenuti web all\'interno dell\'app + Jam di gruppo + Broker MQTT + Host del broker + %1$s:%2$d + Porta del broker + Usa TLS + Nome utente (facoltativo) + Password (facoltativa) + Prefisso ID client + Usato per identificare questa istanza di Spotube + Crittografa la connessione al broker + Verifica le impostazioni attuali del broker prima di avviare una stanza. + Verifica connessione + Verifica in corso… + Connesso in %1$d ms + Non riuscito: %1$s + Broker di esempio: configurane uno tuo per l\'hosting autonomo + Connessione + Credenziali + Identità + Diagnostica + + + Jam di gruppo + Crea + Partecipa + Crea una stanza, poi condividi il codice con i tuoi amici. + Crea stanza + Entra nella stanza + Connessione… + Codice stanza + ABC123 + Broker: %1$s + Configura un broker nelle Impostazioni per ospitare una stanza o parteciparvi. + Impostazioni broker + Connesso + Riconnessione… + Copia codice + Copiato! + Condividi + Partecipanti (%1$d) + Host + Ascoltatore + In attesa di ascoltatori… + Rimuovi + Banna + Gestisci + Esci dalla stanza + Impossibile connettersi + Ignora + Impossibile creare la sessione Jam + Impossibile partecipare alla sessione Jam + Inserisci un codice stanza di sei caratteri composto da lettere e cifre. + Unisciti alla mia Jam su Spotube con il codice: %1$s + + Vai all\'album + Timer di spegnimento + Dettagli brano + Riproduci + Pausa + Nessuna riproduzione in corso + Aggiungi alla coda + + Ignora + Aggiorna + Spotube %1$s è disponibile + Scopri le novità di questa versione nella pagina dei download di Spotube. + %1$s %2$s è disponibile + Attualmente installata: %1$s + Scopri le novità di questo aggiornamento nella pagina del plugin. + + Salta + Avanti + Indietro + Inizia + Spotube + Musica, senza vincoli. + Un player open source per un mondo di musica.\nTutto da esplorare. Tutto da rendere tuo. + Aiutaci a mantenere vivo questo progetto. + Spotube è creato da persone, non da un\'azienda in abbonamento. Se diventa parte della tua quotidianità, un piccolo contributo ci aiuta a dargli la cura che merita.\n\nGrazie per fare spazio al software indipendente. + Inizia con un plugin di catalogo per trovare brani e playlist. Scegli un plugin audio per riprodurli. Testi e cronologia di ascolto sono facoltativi. Scopri un plugin qui sotto, controlla le sue autorizzazioni, installalo e poi selezionalo qui. + Scopri plugin + Le tue fonti + Riprova + Altri plugin + Nessun nuovo plugin trovato. Puoi anche installarne uno da un link o da un file .smplug qui sopra. + La ricerca dei plugin non è disponibile al momento. Riprova oppure installa da un link o da un file. + Benvenuto + Supporto + Plugin + Guida + + Come usare Spotube + Quattro rapidi passaggi e sei pronto ad ascoltare. + Accedi + Collega il tuo account Spotify o scegli il tuo plugin di catalogo. + Cerca e riproduci + Trova qualsiasi brano, album o artista, poi premi play. + Ascolta offline + Scarica i brani per continuare ad ascoltarli senza connessione. + Segui il testo + Apri i testi sincronizzati dal player mentre ascolti un brano. + + + La tua musica. Le tue fonti. Le tue regole. + Un player musicale gratuito e open source, creato da una community che ama semplicemente la musica. + Open source + Nessuna pubblicità + Nessuna telemetria + Basato su plugin + Iniziamo + + + I sostenitori ci fanno andare avanti + Creato con passione. Tenuto in vita da te. + Spotube è gratuito al 100%, libero e open source: niente pubblicità, niente abbonamenti, nessun tracciamento. Ogni funzione esiste perché persone come te hanno scelto di contribuire allo sviluppo. + Patreon + Sostenitore mensile + Sostieni lo sviluppo continuo e l\'infrastruttura + Buy Me a Coffee + Un caffè una tantum + Alimenta una sessione di programmazione notturna con una piccola mancia + Open Collective + Fondo della community + Sostegno finanziario trasparente e gestito dalla community + Grazie di cuore per credere nella musica libera. + Il sostegno è del tutto facoltativo: Spotube resta comunque gratuito. + Sostieni + Apri %1$s + + + Sostieni Spotube + Sostieni Spotube + Spotube è gratuito, libero e open source: niente pubblicità, niente abbonamenti, nessun tracciamento. Se ti rende felice, valuta di sostenere il progetto. + Puoi disattivare questi promemoria in Impostazioni → Supporto. + Forse più tardi + Promemoria di sostegno + Mostra un discreto promemoria per sostenere il progetto ogni 7 giorni. + + + Architettura modulare + Scegli da dove arriva la tua musica. + Spotube separa la ricerca nel catalogo, lo streaming audio e i testi sincronizzati. Configura i tuoi motori qui sotto o cambiali in qualsiasi momento. + Motore metadati + Gestisce scoperta musicale, ricerca, elenchi di brani e playlist. + Motore audio + Recupera e decodifica i flussi audio da riprodurre. + Motore testi + Trasmette testi karaoke sincronizzati in tempo reale. + Motore scrobbling + Registra la cronologia di ascolto su Last.fm o ListenBrainz. + Attivo + Integrato + Pronto + Facoltativo + Seleziona un motore + Motori installati + Imposta come predefinito + NewPipe YouTube e LRCLib Lyrics sono integrati e pronti per lo streaming fin da subito. + Gestisci o aggiungi nuovi plugin della community in qualsiasi momento in Impostazioni → Plugin. + + + Architettura retro-moderna + Puro Hi-Fi, zero intermediari + Ecco come Spotube gestisce la riproduzione decentralizzata direttamente sul tuo dispositivo. + PEER-TO-PEER DIRETTO • OPUS 256 KBPS + Ora blu + Spotube · dimostrazione del player + Un po\' di musica per i momenti di mezzo. + 1. Ricerca decentralizzata + Le ricerche vanno direttamente al plugin di catalogo scelto. Spotube non profila mai i tuoi gusti. + 2. Risoluzione diretta degli stream + L\'audio arriva direttamente dai server dei contenuti al tuo dispositivo. Nessun proxy intermedio né raccolta remota di dati. + 3. DSP e riproduzione in locale + Decodificato e salvato in cache in locale, con equalizzatore e download offline facoltativo. + 4. Testi sincronizzati e Jam + I testi karaoke seguono il tempo di riproduzione, con controllo remoto via LAN e Jam di gruppo. + Nessun server intermediario. La riproduzione avviene direttamente sul tuo dispositivo: privacy by design. + + + Scegli un dispositivo + %1$s: «%2$s» + Riproduci + Aggiungi alla coda + Riproduci dopo + Questo dispositivo + Su questo dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + Sul dispositivo connesso + + + Connessione non riuscita + Connessione persa + Invio non riuscito + Ricerca non riuscita: %1$s + Impossibile connettersi: %1$s + + + Dispositivi + Aggiorna + Connesso a %1$s:%2$d + Disconnetti + Connessione... + Ricerca di dispositivi sulla rete... + Nessun dispositivo trovato + Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni. + Dispositivo sconosciuto + Risoluzione... + ID: %1$s... + + + Controllo remoto + Disconnesso + Errore di connessione: %1$s + Copertina dell\'album + Brano sconosciuto + Artista sconosciuto + Posizione + Riproduzione casuale + Precedente + Successivo + Modalità ripetizione + Volume + Rimuovi dalla coda + Altre opzioni + + + Dove riprodurre? + Hai una sessione Jam attiva. Scegli dove riprodurre questa raccolta. + Riproduci qui + Suggerisci alla Jam + + + Nessun broker Jam configurato + L\'host del broker è vuoto + Connessione non riuscita + Connessione persa + + + Broker + anonimo + + + Aggiunto alla coda della Jam + + %1$d brano aggiunto alla coda della Jam + %1$d brani aggiunti alla coda della Jam + %1$d brani aggiunti alla coda della Jam + + + + Richiesta di controllo remoto + \"%1$s\" vuole controllare la riproduzione su questo dispositivo. + Rifiuta + Consenti + Consenti sempre + + + Risultati della ricerca + Tutto + Brani + Utenti + Nessun tipo di ricerca disponibile + Inizia a digitare per cercare + Inizia a digitare per cercare brani + Nessun risultato trovato + Mostra tutto + Ricerca in corso + Caricamento brani... + Caricamento album... + Caricamento artisti... + Caricamento playlist... + Caricamento utenti... + Nessun album trovato + Nessun artista trovato + Nessuna playlist trovata + Nessun utente trovato + Frugando tra le casse di dischi... + Esplorando il cosmo... + Scaldando le casse... + Spolverando i vinili... + Consultando gli dei della musica... + Sintonizzando le antenne... + Sfogliando i dischi... + Impossibile caricare le funzionalità di ricerca + Ricerca non riuscita + Impossibile cercare i brani + Impossibile cercare gli album + Impossibile cercare gli artisti + Impossibile cercare le playlist + Impossibile cercare gli utenti + + + Sfoglia + + + Sincronizzati + Semplici + Nessun testo sincronizzato disponibile + Nessun testo semplice disponibile + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d can. + Nessun plugin di metadati selezionato + + + Indietro + Riprova + Chiudi finestra di dialogo + Caricamento... + Sconosciuto + Errore sconosciuto + + + Utente + Playlist + + + Ci dispiace davvero + Qualcosa non sta funzionando in questo momento. La tua esperienza è importante per noi e stiamo facendo del nostro meglio per risolvere il problema. Riprova o controlla i dettagli dell\'errore qui sotto. + Dettagli dell\'errore + Copia negli appunti + Visualizza log completo + Log completo dell\'errore + + + Riproduzione casuale + Riproduci in ordine casuale + Segui + Non seguire più + Modifica playlist + Di %1$s + + + Opzioni brano + Aggiungi alla Jam + Avvia radio + Riproduci dopo + Sposta come successivo + Rimuovi dalla coda + Rimuovi dai preferiti + Salva nei preferiti + Aggiungi alla playlist + Scarica + Condividi + + + Originale + Titolo + Artista + Album + Durata + Ordina + Nessun brano trovato + Filtra + Azioni multiple + Scarica tutto + Scarica %1$d + Aggiungi tutto alla coda + Aggiungi %1$d alla coda + Riproduci tutto dopo + Riproduci %1$d dopo + Aggiungi tutto alla playlist + Aggiungi %1$d alla playlist + Aggiungi tutto alla Jam + Aggiungi %1$d alla Jam + +%1$d altri + + + Nessun URL da caricare + Torna indietro nella cronologia del browser + Vai avanti nella cronologia del browser + + + Filtra blacklist... + Nessun elemento in blacklist + Nessun elemento in blacklist corrisponde a \'%1$s\' + Brani + Brano + Aggiungi alla blacklist + Rimuovi dalla blacklist + + + Nessun metadato del brano + Impossibile risolvere l\'URL dello stream + Impossibile risolvere l\'URL dello stream. Prova prima a riprodurre il brano. + + + Brani salvati + Tu + + + Segui già + Brani più popolari + Riproduci i brani più popolari + Aggiungi i brani più popolari alla coda + Artisti simili + Playlist in evidenza + Impossibile caricare la panoramica dell\'artista + Follower non disponibili + %1$s K follower + %1$s Mln follower + %1$s Mld follower + + %1$d follower + %1$d follower + %1$d follower + + + + Mi piace + Non mi piace più + Titolo brano segnaposto + Artisti segnaposto + Album sconosciuto + Posizione + Volume + Attiva riproduzione casuale + Disattiva riproduzione casuale + Precedente + Successivo + Riproduci o metti in pausa + Ripetizione disattivata + Ripeti brano + Ripeti tutto + Disattiva audio + Riattiva audio + Altre opzioni + Opzioni del player + Comprimi player + Espandi testo + In riproduzione + Testo + Fonte alternativa + Fonte alternativa + Scarica + Scaricato + Download in corso... + Già scaricato + Nuovo tentativo di download... + Download di %1$s + La riproduzione si interromperà tra %1$s. + Metti in pausa la riproduzione dopo il numero di minuti selezionato. + Minuti + Scegli tra 1 e 600 minuti + Annulla timer + Avvia + L\'URL di riproduzione è stato risolto con impostazioni di streaming diverse. Lo stream evidenziato corrisponde alle tue preferenze attuali; l\'URL attivo verrà aggiornato alla prossima richiesta di stream. + Fonte audio abbinata + Caricamento della fonte abbinata… + Una fonte remota abbinata non è necessaria durante la riproduzione del file locale in cache. + Nessuna fonte abbinata disponibile. + Nessuna fonte audio abbinata in cache per questo brano. + I dettagli dello stream non sono ancora in cache. Riproduci prima il brano per caricarli. + Impossibile caricare i dettagli del brano. + Stream disponibili + Questo brano viene riprodotto dalla cache locale. Le varianti di stream non vengono usate per questa riproduzione. + Caricamento degli stream… + Nessuno stream disponibile per questa fonte. + Stream attualmente selezionato + Corrisponde alle tue preferenze di formato e qualità dello streaming + URL di riproduzione attivo + Riprodotto dalla cache locale + Brano dei metadati + Titolo + ID + Album + Affidabilità + URI esterno + ID album + Tipo di album + Data di uscita dell\'album + Numero di brani dell\'album + Generi dell\'album + Durata + Numero del brano + Numero del disco + Esplicito + Popolarità + ISRC + URL delle miniature + + + Mostra/nascondi barra laterale + + + Fonti alternative + Nessun brano in riproduzione + Ricerca di fonti alternative... + Nessuna fonte alternativa trovata + Affidabilità: %1$d% + Fonte attiva + + + Espelli + Rimuovilo dalla sessione + Espellilo e impediscigli di rientrare + Rimuovi suggerimenti + Rimuovi tutti i brani che ha aggiunto alla coda + Riordina + Rimuovi dalla coda + %1$s • Aggiunto da %2$s + + + Sconosciuto + Caricamento playlist... + Aggiungi brani + Aggiungi brani alla playlist + Cerca brani... + Cerca + Nessun brano trovato + Cerca brani da aggiungere + Fine + Aggiungi + Aggiunto + + + Caricamento album... + + + Aggiungi a una tua playlist + Cerca + Cerca nelle tue playlist... + Caricamento delle tue playlist... + Non hai ancora nessuna playlist.\nCreane una per aggiungere brani. + Crea playlist + Modifica playlist + Crea + Nome + Nome della playlist + Descrizione + Descrizione (facoltativa) + Pubblica + Chiunque può vedere questa playlist + Collaborativa + Altre persone possono aggiungere brani a questa playlist + Immagine di copertina + Copertina della playlist + Nessuna immagine + Cambia immagine + Scegli immagine + + + Installa + Sostituisci + plugin.json non trovato nel file zip + manifest.zipline.json non trovato nel file zip + Formato di plugin.json non valido: %1$s + Impossibile leggere il plugin: %1$s + Impossibile installare il plugin: %1$s + È disponibile una versione più recente di %1$s. Conferma l\'aggiornamento prima di installarla. + %1$s è già installato. Conferma la sostituzione prima di installarlo. + I plugin integrati sono già inclusi e non possono essere aggiunti di nuovo. + I plugin integrati non possono essere rimossi. + %1$s dichiara un apiVersion non valido (%2$s). È previsto un versionamento semantico compatibile con %3$s. + La versione API dei plugin di Spotube %1$s non è valida. + %1$s è destinato all\'API plugin %2$s, ma Spotube supporta l\'API plugin %3$s con modifiche incompatibili. Installa un plugin creato per la stessa versione principale dell\'API. + + + API del plugin non compatibile + Installare il plugin? + %1$s verrà aggiunto ai plugin installati. + Aggiornare il plugin? + È installata una versione precedente (%1$s). Aggiornare a %2$s? + Sostituire il plugin installato? + %1$s usa una versione semantica non valida (%2$s) e non può essere confrontato come aggiornamento. Sostituire comunque il plugin installato? + La versione installata (%1$s) non può essere confrontata tramite versionamento semantico. Sostituirla con %2$s? + La versione %1$s è già installata. Sostituire il plugin esistente con la copia fornita? + La versione installata %1$s è più recente della versione fornita %2$s. Sostituirla comunque? + Sostituire il plugin installato con la copia fornita? + + + %1$s: + Versione + Versione API + Autore + Licenza + Funzionalità + Abilità + Repository + Contatto + Segnala bug + Sostieni %1$s + + + Ufficiale + Verificato + Stelle su GitHub + URL del repository GitHub + + + Nessuna versione trovata + Seleziona versione + Seleziona una versione + Pre-release + Note di rilascio + + + Home + Cerca + Sul dispositivo + Connect + Dispositivi + + + Nascondi finestra + Mostra finestra + Brano successivo + Brano precedente + Riproduzione casuale + Ripetizione + Ripetizione: NO + Ripetizione: UNO + Ripetizione: TUTTI + Volume: %1$d% + Volume + + Volume - + Disattiva audio + Riattiva audio + Metti Mi piace al brano + Togli Mi piace al brano + Esci + + + Riduci a icona + Ingrandisci + Ripristina + Chiudi + + + Servizio di riproduzione + Riproduzione + Controlli di riproduzione multimediale + + + Sfoglia + In riproduzione + In evidenza + Brani salvati + Brani più popolari + diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml new file mode 100644 index 00000000..9976483c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -0,0 +1,932 @@ + + + + 再生キュー + キューをクリア + キューを絞り込み... + キューに曲がありません + + 曲、アーティスト、アルバムを検索... + 履歴をすべて消去 + 検索... + + + ライブラリ + ライブラリ + プレイリスト + アルバム + アーティスト + ローカルの曲 + ダウンロード + 保存したプレイリストを検索… + 保存したアルバムを検索… + 保存したアーティストを検索… + フォルダや曲を検索… + ライブラリを絞り込み + 絞り込みを閉じる + プレイリストを作成 + ローカルの曲を更新 + グリッド表示に切り替え + リスト表示に切り替え + 保存したプレイリストはありません + 「%1$s」に一致するプレイリストはありません + 保存したアルバムはありません + 「%1$s」に一致するアルバムはありません + 保存したアーティストはありません + 「%1$s」に一致するアーティストはありません + + + フォルダ + フォルダ + %1$s • %2$s + + %1$d曲 + + + %1$d個のフォルダ + + フォルダ一覧に戻る + フォルダを再生 + オーディオへのアクセスが必要です + デバイス内の音楽を表示するには、オーディオへのアクセスを許可してください。 + アクセスを許可 + ローカルフォルダが見つかりません + このフォルダには曲がありません + 一致するフォルダや曲はありません + ローカルメディアを更新できませんでした + + + %1$d件進行中 • %2$d件完了 + 完了済みを消去 + ダウンロードはまだありません + ダウンロード中 %1$d% + 待機中 + 完了 + キャンセル済み + 失敗 + 失敗: %1$s + ダウンロードをキャンセル + ダウンロードを再試行 + ダウンロードを削除 + + + アーティスト + ブラックリスト登録済み + お気に入りの曲 + 保存した曲 + 不明なアーティスト + 曲名 + アーティスト名 + + 選択済み + + 設定 + + プラグイン + 言語と地域 + 外観 + 再生 + ダウンロード + キャッシュ + デスクトップ + アップデート + サポート + このアプリについて + + 閉じる + 保存 + キャンセル + + 言語 + アプリの表示言語。現在: %1$s + システムのデフォルト + 国 + コンテンツと利用可否に使用する国・地域。現在: %1$s + %1$s (%2$s) + + テーマ + Spotubeをシステムの外観にどう合わせるかを選択します。現在: %1$s + ライト + ダーク + システム + + サーフェス + アプリの背景素材。現在: %1$s + ウォーター + アイス + クローム + アクリル + ガラス + + アクセントカラー + 現在のパレット: %1$s + アクセントカラー + アクセントパレットを選び、ライトとダークの両テーマでプレビューできます。 + ライト + ダーク + プレビュー + ボタン + グリーンゴブリン + エレクトリックバイオレット + オーシャンシアン + サンセットオレンジ + ローズガーデン + ミッドナイトブルー + メタリックスレート + + ルック&フィール + サーフェス素材とアクセント。現在: %1$s · %2$s + ルック&フィール + サーフェス素材を選び、それに合うアクセントを選択してください。どの選択肢も厳選された組み合わせです。 + サーフェス + アクセント + セカンダリ + + パレット + Millenniumアクセントパレット。現在: %1$s + パレット + Millenniumアクセントパレットを選び、ライトとダークでプレビューできます。 + パレット + Millenniumパレットとアクセント。現在: %1$s + ブルー + オレンジ + ライム + + ダウンロードフォルダの変更 + 現在のフォルダ: %1$s + 現在のフォルダ: デフォルトの音楽フォルダ + 空欄にするとデフォルトの音楽フォルダを使用します。 + C:\Music\Spotube + ローカルメディアフォルダ + デスクトップでスキャンするカスタムフォルダ。追加済み: %1$d + カスタムフォルダはまだ追加されていません + ローカル音楽ライブラリ用のカスタムフォルダ。 + 管理 + フォルダを追加 + フォルダを削除 + カスタムフォルダは追加されていません。 + %1$sをスキャン対象から削除 + ダウンロードフォルダと音楽キャッシュフォルダは自動的に含まれます。 + ダウンロードフォルダは自動的に含まれます。 + 音楽キャッシュフォルダは自動的に含まれます。 + 保存中… + フォルダを保存できませんでした。変更内容は保持されていますので、もう一度お試しください。 + + %1$d個のフォルダ + + ダウンロードの音楽フォーマット + ダウンロードに使用するコーデックとコンテナ。現在: %1$s + ダウンロードの音質 + + ストリーミングの音楽フォーマット + ストリーミング再生に使用するコーデックとコンテナ。現在: %1$s + ストリーミングの音質 + 現在: %1$s + 音楽のキャッシュを有効化 + ストリーミングした音声をローカルに保存し、再度の再生を高速化します。 + キャッシュフォルダ + 現在: %1$s + 現在: アプリのデフォルトキャッシュ + キャッシュサイズの上限 + 上限なし + 現在の上限: %1$d MB + キャッシュの最大サイズをMB単位で設定します。0を入力するか空欄にすると上限なしになります。 + 500 + サイズは0または正の数で指定してください。 + エンドレス再生を有効化 + キューが終わったら、おすすめの曲で再生を続けます。 + Connectを有効化 + Spotube Connect機能でリモート再生コントロールを公開します。 + 再生プロキシサーバーのポート + リモート操作を許可 + 同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。 + リモート操作用のデバイス名 + 他のデバイスがこのデバイスを検出したときに表示される名前。現在: %1$s + ネットワーク上の他のデバイスがこのSpotubeを検出したときに表示される、わかりやすい名前を付けてください。 + 例: リビングのSpotube + デバイスのホスト名 + 再生プロキシサーバーが使用するポート。現在: %1$d + 1〜65535のポートを選択してください。 + 14769 + 有効な整数を入力してください。 + ポートは1〜65535の範囲で指定してください。 + + ブラックリスト + ブラックリストに登録した曲とアーティストを管理 + + トレイに最小化 + ウィンドウを最小化したとき、Spotubeをシステムトレイで実行し続けます。 + Discordリッチプレゼンス + デスクトップで現在の再生状況をDiscordに表示します。 + + アップデートを自動確認 + Spotubeの新しいリリースをバックグラウンドで確認します。 + + プラグインを管理 + プラグインを管理します + デフォルトの%1$sプラグイン + メタデータ + オーディオ + 歌詞 + Scrobble + プラグインが選択されていません + 変更 + 選択 + 選択済み + 選択を解除 + 利用できるプラグインはありません + %1$sプラグイン + クリア + + 構成 + プラグインマネージャー + URLを入力してください + URLは http:// または https:// で始まる必要があります + プラグインをダウンロードできませんでした + プラグインはまだインストールされていません + 上でダウンロードURLを貼り付けるか、ファイルを選択してください。 + + %1$d個のプラグインをインストール済み + + プラグインをインストール + https://example.com/plugin.smplug + ダウンロード + ファイルからインストール (.smplug) + 有効 + v%1$s + 内蔵 + プラグインを削除 + ログイン + ログアウト + URLからダウンロード + ファイルからインストール + インストール + + • %1$s + 要求される権限 + このプラグインに特別な権限は必要ありません。 + インストール済み vs 提供版 + インストール済み + 提供版 + API %1$s → %2$s + 永続ストレージ + ディスクへのデータの読み書き + ネットワークリクエスト + インターネット経由でのデータの送受信 + WebView + アプリ内でのWebコンテンツの表示 + グループジャム + MQTTブローカー + ブローカーのホスト + %1$s:%2$d + ブローカーのポート + TLSを使用 + ユーザー名 (任意) + パスワード (任意) + クライアントIDのプレフィックス + このSpotubeを識別するために使用されます + ブローカーとの接続を暗号化します + ルームを開始する前に、現在のブローカー設定を確認します。 + 接続をテスト + テスト中… + %1$d msで接続しました + 失敗: %1$s + 仮のブローカーです。セルフホストするには独自のブローカーを設定してください + 接続 + 認証情報 + 識別情報 + 診断 + + + グループジャム + 作成 + 参加 + ルームを作成して、コードを友達に共有しましょう。 + ルームを作成 + ルームに参加 + 接続中… + ルームコード + ABC123 + ブローカー: %1$s + ルームを主催または参加するには、設定でブローカーを設定してください。 + ブローカー設定 + 接続済み + 再接続中… + コードをコピー + コピーしました! + 共有 + 参加者 (%1$d) + ホスト + リスナー + リスナーを待っています… + 削除 + BAN + 管理 + ルームから退出 + 接続できませんでした + 閉じる + ジャムセッションを作成できませんでした + ジャムセッションに参加できませんでした + 英数字6文字のルームコードを入力してください。 + コード %1$s で私のSpotubeジャムに参加しよう + + アルバムへ移動 + スリープタイマー + 曲の詳細 + 再生 + 一時停止 + 再生中の曲はありません + キューに追加 + + 無視 + アップデート + Spotube %1$s が利用可能です + このリリースの新機能はSpotubeのダウンロードページでご確認ください。 + %1$s %2$s が利用可能です + インストール済み: %1$s + このアップデートの新機能はプラグインのページでご確認ください。 + + スキップ + 次へ + 戻る + はじめる + Spotube + 音楽を、しがらみなく。 + 音楽の世界へ広がる、オープンソースのプレーヤー。\n自由に探して、自分らしく楽しもう。 + このプロジェクトを支えてください。 + Spotubeはサブスクリプションビジネスではなく、人の手でつくられています。日々の暮らしの一部になったなら、少しのご支援が、このアプリにふさわしい手入れを続ける力になります。\n\n独立したソフトウェアに居場所をつくってくださり、ありがとうございます。 + まずはカタログプラグインで曲やプレイリストを探せるようにしましょう。次に、再生用のオーディオプラグインを選びます。歌詞と再生履歴は任意です。下からプラグインを見つけて権限を確認し、インストールしてからここで選択してください。 + プラグインを探す + ソース + 再試行 + その他のプラグイン + 新しいプラグインは見つかりませんでした。上のリンクや.smplugファイルからインストールすることもできます。 + 現在プラグインを検索できません。再試行するか、リンクまたはファイルからインストールしてください。 + ようこそ + サポート + プラグイン + ガイド + + Spotubeの使い方 + 簡単な4ステップで、すぐに聴けます。 + サインイン + Spotifyアカウントを連携するか、お好みのカタログプラグインを選んでください。 + 検索して再生 + 曲、アルバム、アーティストを探して再生しましょう。 + オフラインで聴く + 曲をダウンロードすれば、接続がなくても再生できます。 + 歌詞を表示 + 曲の再生中に、プレーヤーから同期歌詞を開けます。 + + + あなたの音楽。あなたのソース。あなたのルール。 + 音楽を愛するコミュニティがつくった、無料でオープンソースの音楽プレーヤー。 + オープンソース + 広告なし + テレメトリなし + プラグインで拡張 + はじめよう + + + サポーターの皆さんに支えられています + 情熱でつくり、あなたが支える。 + Spotubeは100%無料の自由なオープンソースソフトウェアです。広告も、サブスクリプションも、トラッキングもありません。すべての機能は、あなたのような人たちが開発を支えてくれたからこそ存在しています。 + Patreon + 毎月のスポンサー + 継続的な開発とインフラを支援 + Buy Me a Coffee + 1回限りのコーヒー + ちょっとしたチップで深夜のコーディングを応援 + Open Collective + コミュニティ基金 + 透明性が高く、コミュニティが運営する資金支援 + オープンな音楽を信じてくださり、心から感謝します。 + 支援はまったくの任意です。どちらでもSpotubeは無料のままです。 + 支援する + %1$sを開く + + + Spotubeを支援 + Spotubeを支援 + Spotubeは無料の自由なオープンソースソフトウェアです。広告も、サブスクリプションも、トラッキングもありません。気に入っていただけたら、プロジェクトへのご支援をご検討ください。 + このリマインダーは「設定 → サポート」でオフにできます。 + あとで + 支援のリマインダー + 7日ごとに、プロジェクトへの支援をさりげなくお知らせします。 + + + モジュール式アーキテクチャ + 音楽をどこから届けるか、選びましょう。 + Spotubeはカタログの検索、オーディオのストリーミング、同期歌詞をそれぞれ分離しています。下でエンジンを設定でき、いつでも切り替えられます。 + メタデータエンジン + 音楽の発見、検索、曲リスト、プレイリストを担います。 + オーディオエンジン + 再生するオーディオストリームを取得してデコードします。 + 歌詞エンジン + 時間同期されたカラオケ歌詞をリアルタイムで表示します。 + Scrobbleエンジン + 再生履歴をLast.fmやListenBrainzに記録します。 + 有効 + 内蔵 + 準備完了 + 任意 + エンジンを選択 + インストール済みのエンジン + デフォルトに設定 + NewPipe YouTubeとLRCLib Lyricsは内蔵されており、すぐにストリーミングできます。 + コミュニティプラグインの管理や追加は、いつでも「設定 → プラグイン」から行えます。 + + + レトロモダンなアーキテクチャ + 純粋なHi-Fi、仲介者ゼロ + Spotubeがあなたのマシン上で分散型の再生を直接制御する仕組みをご紹介します。 + ダイレクトP2P • 256 KBPS OPUS + ブルーアワー + Spotube · プレーヤーのデモ + 合間のひとときに、少しの音楽を。 + 1. 分散型の検索 + 検索クエリは選択したカタログプラグインに直接送られます。Spotubeがあなたの好みをプロファイリングすることはありません。 + 2. ストリームを直接解決 + オーディオはコンテンツサーバーからあなたのマシンへ直接ストリーミングされます。プロキシの経由やリモートでのデータ収集はありません。 + 3. ローカルDSPと再生 + ローカルでデコード・キャッシュされ、イコライザーやオフライン用ダウンロード (任意) も使えます。 + 4. 同期歌詞とジャム + カラオケ歌詞が再生位置に合わせて流れ、LANでのリモート操作やグループジャムにも対応します。 + 仲介サーバーはありません。再生はすべてあなたのデバイス上で行われ、設計段階からプライバシーを守ります。 + + + デバイスを選択 + %1$s:「%2$s」 + 再生 + キューに追加 + 次に再生 + このデバイス + このデバイスで + リモートデバイス + リモートデバイス (%1$s) + 接続中のデバイスで + + + 接続に失敗しました + 接続が切断されました + 送信に失敗しました + 検出に失敗しました: %1$s + 接続できませんでした: %1$s + + + デバイス + 更新 + %1$s:%2$d に接続済み + 切断 + 接続中... + ネットワーク上のデバイスを検索中... + デバイスが見つかりません + 相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。 + 不明なデバイス + 解決中... + ID: %1$s... + + + リモート操作 + 切断されました + 接続エラー: %1$s + アルバムカバー + 不明な曲 + 不明なアーティスト + シーク + シャッフル + 前へ + 次へ + リピートモード + 音量 + キューから削除 + その他のオプション + + + どこで再生しますか? + ジャムセッションが進行中です。このコレクションを再生する場所を選んでください。 + ここで再生 + ジャムに提案 + + + ジャム用ブローカーが設定されていません + ブローカーのホストが空です + 接続に失敗しました + 接続が切断されました + + + ブローカー + anonymous + + + ジャムのキューに追加しました + + %1$d曲をジャムのキューに追加しました + + + + リモート操作のリクエスト + 「%1$s」がこのデバイスの再生を操作しようとしています。 + 拒否 + 許可 + 常に許可 + + + 検索結果 + すべて + 曲 + ユーザー + 利用できる検索タイプはありません + 入力して検索 + 入力して曲を検索 + 結果が見つかりません + すべて表示 + 検索中 + 曲を読み込み中... + アルバムを読み込み中... + アーティストを読み込み中... + プレイリストを読み込み中... + ユーザーを読み込み中... + アルバムが見つかりません + アーティストが見つかりません + プレイリストが見つかりません + ユーザーが見つかりません + レコード棚を掘り返しています... + 宇宙を探索しています... + スピーカーをウォームアップしています... + レコードのほこりを払っています... + 音楽の神様にお伺いを立てています... + アンテナを調整しています... + レコードをめくっています... + 検索機能を読み込めませんでした + 検索できませんでした + 曲を検索できませんでした + アルバムを検索できませんでした + アーティストを検索できませんでした + プレイリストを検索できませんでした + ユーザーを検索できませんでした + + + ブラウズ + + + 同期 + テキスト + 同期歌詞はありません + テキスト歌詞はありません + + + %1$s (%2$s) + %1$d kbps + %1$s kHz • %2$d ch + メタデータプラグインが選択されていません + + + 戻る + 再試行 + ダイアログを閉じる + 読み込み中... + 不明 + 不明なエラー + + + ユーザー + プレイリスト + + + 申し訳ありません + 現在、一部の機能が正しく動作していません。ご不便をおかけして申し訳ありません。解決に全力で取り組んでいます。もう一度お試しいただくか、下のエラー詳細をご確認ください。 + エラーの詳細 + クリップボードにコピー + ログ全体を表示 + エラーログ全体 + + + シャッフル + シャッフル再生 + フォロー + フォロー解除 + プレイリストを編集 + 作成者: %1$s + + + 曲のオプション + ジャムに追加 + ラジオを開始 + 次に再生 + 次に移動 + キューから削除 + お気に入りから削除 + お気に入りに保存 + プレイリストに追加 + ダウンロード + 共有 + + + 元の順序 + タイトル + アーティスト + アルバム + 再生時間 + 並べ替え + 曲が見つかりません + 絞り込み + 一括操作 + すべてダウンロード + %1$d曲をダウンロード + すべてキューに追加 + %1$d曲をキューに追加 + すべて次に再生 + %1$d曲を次に再生 + すべてプレイリストに追加 + %1$d曲をプレイリストに追加 + すべてジャムに追加 + %1$d曲をジャムに追加 + 他%1$d組 + + + 読み込むURLがありません + ブラウザの履歴を戻る + ブラウザの履歴を進む + + + ブラックリストを絞り込み... + ブラックリストに登録された項目はありません + 「%1$s」に一致するブラックリスト項目はありません + 曲 + 曲 + ブラックリストに追加 + ブラックリストから削除 + + + 曲のメタデータがありません + ストリームURLを解決できませんでした + ストリームURLを解決できませんでした。先に曲を再生してみてください。 + + + 保存した曲 + あなた + + + フォロー中 + 人気曲 + 人気曲を再生 + 人気曲をキューに追加 + 関連アーティスト + 注目のプレイリスト + アーティストの概要を読み込めませんでした + フォロワー数を取得できません + フォロワー %1$sK人 + フォロワー %1$sM人 + フォロワー %1$sB人 + + フォロワー %1$d人 + + + + お気に入りに追加 + お気に入りから削除 + No-op の曲名 + No-op のアーティスト + 不明なアルバム + シーク + 音量 + シャッフルをオン + シャッフルをオフ + 前へ + 次へ + 再生/一時停止 + リピート: オフ + リピート: 1曲 + リピート: すべて + ミュート + ミュート解除 + その他のオプション + プレーヤーのオプション + プレーヤーを折りたたむ + 歌詞を展開 + 再生中 + 歌詞 + 代替ソース + 代替ソース + ダウンロード + ダウンロード済み + ダウンロード中... + ダウンロード済みです + ダウンロードを再試行中... + %1$sをダウンロード中 + %1$s後に再生を一時停止します。 + 選択した時間 (分) が経過したら再生を一時停止します。 + 分 + 1〜600分の間で選択 + タイマーをキャンセル + 開始 + 再生URLは異なるストリーミング設定で解決されました。ハイライトされたストリームが現在の設定に一致しています。有効なURLは次回のストリームリクエスト時に更新されます。 + 一致したオーディオソース + 一致したソースを読み込み中… + ローカルのキャッシュファイルを再生中は、一致するリモートソースは不要です。 + 一致するソースはありません。 + この曲に一致するオーディオソースはキャッシュされていません。 + ストリームの詳細はまだキャッシュされていません。読み込むには先に曲を再生してください。 + 曲の詳細を読み込めませんでした。 + 利用可能なストリーム + この曲はローカルキャッシュから再生されています。この再生ではストリームのバリエーションは使用されません。 + ストリームを読み込み中… + このソースで利用できるストリームはありません。 + 現在選択中のストリーム + ストリーミングのフォーマットと音質の設定に一致 + 有効な再生URL + ローカルキャッシュから再生 + メタデータの曲 + タイトル + ID + アルバム + 一致度 + 外部URI + アルバムID + アルバムの種類 + アルバムのリリース日 + アルバムの曲数 + アルバムのジャンル + 再生時間 + トラック番号 + ディスク番号 + Explicit + 人気度 + ISRC + サムネイルURL + + + サイドバーの表示切り替え + + + 代替ソース + 再生中の曲はありません + 代替ソースを検索中... + 代替ソースが見つかりません + 一致度: %1$d% + 使用中のソース + + + キック + セッションから退出させます + キックして再参加できないようにします + 提案を削除 + このユーザーがキューに追加した曲をすべて削除します + 並べ替え + キューから削除 + %1$s • %2$sが追加 + + + 不明 + プレイリストを読み込み中... + 曲を追加 + プレイリストに曲を追加 + 曲を検索... + 検索 + 曲が見つかりません + 追加する曲を検索 + 完了 + 追加 + 追加済み + + + アルバムを読み込み中... + + + プレイリストに追加 + 検索 + プレイリストを検索... + プレイリストを読み込み中... + 所有しているプレイリストはまだありません。\nプレイリストを作成して曲を追加しましょう。 + プレイリストを作成 + プレイリストを編集 + 作成 + 名前 + プレイリスト名 + 説明 + 説明 (任意) + 公開 + 誰でもこのプレイリストを見られます + 共同編集 + 他のユーザーもこのプレイリストに曲を追加できます + カバー画像 + プレイリストのカバー + 画像なし + 画像を変更 + 画像を選択 + + + インストール + 置き換え + zipファイル内にplugin.jsonが見つかりません + zipファイル内にmanifest.zipline.jsonが見つかりません + plugin.jsonの形式が無効です: %1$s + プラグインを読み込めませんでした: %1$s + プラグインをインストールできませんでした: %1$s + %1$sの新しいバージョンがあります。インストールする前にアップデートを確認してください。 + %1$sはすでにインストールされています。インストールする前に置き換えを確認してください。 + 内蔵プラグインはすでに含まれているため、再度追加することはできません。 + 内蔵プラグインは削除できません。 + %1$sで宣言されたapiVersion (%2$s) が無効です。%3$sと互換性のあるセマンティックバージョニングが必要です。 + SpotubeのプラグインAPIバージョン %1$s が無効です。 + %1$sはプラグインAPI %2$s を対象としていますが、SpotubeがサポートするのはプラグインAPI %3$s (破壊的変更を含むバージョン) です。同じメジャーAPIバージョン向けにビルドされたプラグインをインストールしてください。 + + + プラグインAPIに互換性がありません + プラグインをインストールしますか? + %1$sがインストール済みプラグインに追加されます。 + プラグインをアップデートしますか? + 古いバージョン (%1$s) がインストールされています。%2$sにアップデートしますか? + インストール済みのプラグインを置き換えますか? + %1$sは無効なセマンティックバージョン (%2$s) を使用しているため、アップデートとして比較できません。それでもインストール済みのプラグインを置き換えますか? + インストール済みのバージョン (%1$s) はセマンティックバージョニングで比較できません。%2$sで置き換えますか? + バージョン %1$s はすでにインストールされています。既存のプラグインを提供されたものに置き換えますか? + インストール済みのバージョン %1$s は提供されたバージョン %2$s より新しいです。それでも置き換えますか? + インストール済みのプラグインを提供されたものに置き換えますか? + + + %1$s: + バージョン + APIバージョン + 作者 + ライセンス + 権限 + 機能 + リポジトリ + 連絡先 + バグを報告 + %1$sを支援 + + + 公式 + 認証済み + GitHubのスター + GitHubリポジトリのURL + + + リリースが見つかりません + リリースを選択 + リリースを選択してください + プレリリース + リリースノート + + + ホーム + 検索 + デバイス内 + Connect + デバイス + + + ウィンドウを隠す + ウィンドウを表示 + 次の曲 + 前の曲 + シャッフル + リピート + リピート: オフ + リピート: 1曲 + リピート: すべて + 音量: %1$d% + 音量 + + 音量 - + ミュート + ミュート解除 + お気に入りに追加 + お気に入りから削除 + 終了 + + + 最小化 + 最大化 + 元に戻す + 閉じる + + + 再生サービス + 再生 + メディア再生コントロール + + + ブラウズ + 再生中 + 注目 + 保存した曲 + 人気曲 + diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml new file mode 100644 index 00000000..f0b8409e --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -0,0 +1,932 @@ + + + + 대기열 + 대기열 비우기 + 대기열 필터... + 대기열이 비어 있어요 + + 노래, 아티스트, 앨범 검색... + 기록 모두 지우기 + 검색... + + + 내 라이브러리 + 라이브러리 + 플레이리스트 + 앨범 + 아티스트 + 로컬 트랙 + 다운로드 + 저장한 플레이리스트 검색… + 저장한 앨범 검색… + 저장한 아티스트 검색… + 폴더 또는 트랙 검색… + 라이브러리 필터 + 필터 닫기 + 플레이리스트 만들기 + 로컬 트랙 새로고침 + 그리드 보기로 전환 + 목록 보기로 전환 + 저장한 플레이리스트가 없어요 + “%1$s”와(과) 일치하는 플레이리스트가 없어요 + 저장한 앨범이 없어요 + “%1$s”와(과) 일치하는 앨범이 없어요 + 저장한 아티스트가 없어요 + “%1$s”와(과) 일치하는 아티스트가 없어요 + + + 폴더 + 폴더 + %1$s • %2$s + + 트랙 %1$d개 + + + 폴더 %1$d개 + + 폴더로 돌아가기 + 폴더 재생 + 오디오 접근 권한 필요 + 기기의 음악을 둘러보려면 오디오 접근을 허용하세요. + 접근 허용 + 로컬 폴더를 찾을 수 없어요 + 이 폴더에 트랙이 없어요 + 일치하는 폴더나 트랙이 없어요 + 로컬 미디어를 새로고침하지 못했어요 + + + 진행 중 %1$d개 • 완료 %2$d개 + 완료된 항목 지우기 + 아직 다운로드한 항목이 없어요 + 다운로드 중 %1$d% + 대기 중 + 완료됨 + 취소됨 + 실패 + 실패: %1$s + 다운로드 취소 + 다운로드 다시 시도 + 다운로드 삭제 + + + 아티스트 + 차단됨 + 좋아요 표시한 곡 + 저장한 노래 + 알 수 없는 아티스트 + 트랙 제목 + 아티스트 이름 + + 선택됨 + + 설정 + + 플러그인 + 언어 및 지역 + 화면 스타일 + 재생 + 다운로드 + 캐시 + 데스크톱 + 업데이트 + 후원 + 정보 + + 닫기 + 저장 + 취소 + + 언어 + 앱 인터페이스 언어입니다. 현재: %1$s + 시스템 기본값 + 국가 + 콘텐츠 및 이용 가능 여부에 적용되는 국가입니다. 현재: %1$s + %1$s (%2$s) + + 테마 + Spotube가 시스템 화면 모드를 따르는 방식을 선택하세요. 현재: %1$s + 라이트 + 다크 + 시스템 + + 표면 + 앱의 배경 재질입니다. 현재: %1$s + 물결 + 얼음 + 크롬 + 아크릴 + 유리 + + 강조 색상 + 현재 팔레트: %1$s + 강조 색상 + 강조 팔레트를 선택하고 라이트 및 다크 테마에서 미리 확인하세요. + 라이트 + 다크 + 미리보기 + 버튼 + 그린 고블린 + 일렉트릭 바이올렛 + 오셔닉 시안 + 선셋 오렌지 + 로즈 가든 + 미드나이트 블루 + 메탈릭 슬레이트 + + 디자인 & 스타일 + 표면 재질과 강조 색상. 현재: %1$s · %2$s + 디자인 & 스타일 + 표면 재질을 고른 다음 어울리는 강조 색상을 선택하세요. 모든 옵션은 엄선된 조합이에요. + 표면 + 강조 색상 + 보조 + + 팔레트 + Millennium 강조 팔레트. 현재: %1$s + 팔레트 + Millennium 강조 팔레트를 선택하고 라이트 및 다크 모드에서 미리 확인하세요. + 팔레트 + Millennium 팔레트 및 강조 색상. 현재: %1$s + 블루 + 오렌지 + 라임 + + 다운로드 폴더 지정 + 현재 폴더: %1$s + 현재 폴더: 기본 음악 폴더 + 기본 음악 폴더를 사용하려면 비워 두세요. + C:\Music\Spotube + 로컬 미디어 폴더 + 데스크톱에서 검색할 사용자 지정 폴더. 추가됨: %1$d + 아직 추가한 사용자 지정 폴더가 없어요 + 로컬 음악 라이브러리용 사용자 지정 폴더입니다. + 관리 + 폴더 추가 + 폴더 삭제 + 추가한 사용자 지정 폴더가 없습니다. + 검색 목록에서 %1$s 삭제 + 다운로드 폴더와 음악 캐시 폴더는 자동으로 포함돼요. + 다운로드 폴더는 자동으로 포함돼요. + 음악 캐시 폴더는 자동으로 포함돼요. + 저장 중… + 폴더를 저장하지 못했어요. 변경 사항은 그대로 남아 있으니 다시 시도하세요. + + 폴더 %1$d개 + + 다운로드 음악 형식 + 다운로드에 사용할 코덱 및 컨테이너. 현재: %1$s + 다운로드 음질 + + 스트리밍 음악 형식 + 음악 스트리밍에 사용할 코덱 및 컨테이너. 현재: %1$s + 스트리밍 음질 + 현재: %1$s + 음악 캐시 사용 + 스트리밍한 오디오를 기기에 저장해 다시 재생할 때 더 빠르게 불러와요. + 캐시 폴더 + 현재: %1$s + 현재: 기본 앱 캐시 + 캐시 용량 제한 + 제한 없음 + 현재 제한: %1$d MB + 최대 캐시 용량을 MB 단위로 설정하세요. 제한을 두지 않으려면 0을 입력하거나 비워 두세요. + 500 + 용량은 0 또는 양수여야 해요. + 끝없는 재생 사용 + 대기열이 끝나면 추천 트랙으로 계속 재생해요. + Connect 사용 + Spotube Connect 기능을 통해 원격 재생 컨트롤을 제공해요. + 재생 프록시 서버 포트 + 원격 제어 허용 + 같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요. + 원격 제어 기기 이름 + 다른 기기가 이 기기를 찾을 때 표시되는 이름입니다. 현재: %1$s + 네트워크의 다른 기기가 이 Spotube를 찾을 때 표시할 이름을 정하세요. + 예: 거실 Spotube + 기기 호스트 이름 + 재생 프록시 서버에서 사용하는 포트입니다. 현재: %1$d + 1에서 65535 사이의 포트를 선택하세요. + 14769 + 올바른 정수를 입력하세요. + 포트는 1에서 65535 사이여야 해요. + + 차단 목록 + 차단한 트랙 및 아티스트 관리 + + 트레이로 최소화 + 창을 최소화하면 Spotube를 시스템 트레이에서 계속 실행해요. + Discord Rich Presence + 데스크톱에서 현재 재생 상태를 Discord에 표시해요. + + 자동으로 업데이트 확인 + 백그라운드에서 Spotube 새 버전을 확인해요. + + 플러그인 관리 + 플러그인을 관리하세요 + 기본 %1$s 플러그인 + 메타데이터 + 오디오 + 가사 + 스크로블 + 선택한 플러그인 없음 + 변경 + 선택 + 선택됨 + 선택 해제 + 사용 가능한 플러그인이 없어요 + %1$s 플러그인 + 지우기 + + 구성 + 플러그인 관리자 + URL을 입력하세요 + URL은 http:// 또는 https://로 시작해야 해요 + 플러그인을 다운로드하지 못했어요 + 아직 설치한 플러그인이 없어요 + 위에 다운로드 URL을 붙여넣거나 파일을 선택하세요. + + 플러그인 %1$d개 설치됨 + + 플러그인 설치 + https://example.com/plugin.smplug + 다운로드 + 파일에서 설치(.smplug) + 사용 중 + v%1$s + 기본 제공 + 플러그인 삭제 + 로그인 + 로그아웃 + URL에서 다운로드 + 파일에서 설치 + 설치 + + • %1$s + 요청된 권한 + 이 플러그인에는 특별한 권한이 필요하지 않아요. + 설치된 버전과 제공된 버전 비교 + 설치됨 + 제공됨 + API %1$s → %2$s + 영구 저장소 + 디스크에 데이터 읽기 및 쓰기 + 네트워크 요청 + 인터넷으로 데이터 송수신 + WebView + 앱 안에 웹 콘텐츠 표시 + 그룹 잼 + MQTT 브로커 + 브로커 호스트 + %1$s:%2$d + 브로커 포트 + TLS 사용 + 사용자 이름(선택 사항) + 비밀번호(선택 사항) + 클라이언트 ID 접두사 + 이 Spotube 인스턴스를 식별하는 데 사용돼요 + 브로커 연결 암호화 + 방을 시작하기 전에 현재 브로커 설정을 확인하세요. + 연결 테스트 + 테스트 중… + %1$d ms 만에 연결됨 + 실패: %1$s + 임시 브로커입니다 — 직접 호스팅하려면 브로커를 설정하세요 + 연결 + 인증 정보 + 식별 정보 + 진단 + + + 그룹 잼 + 만들기 + 참여 + 방을 만든 다음 친구들에게 코드를 공유하세요. + 방 만들기 + 방 참여 + 연결 중… + 방 코드 + ABC123 + 브로커: %1$s + 방을 만들거나 참여하려면 설정에서 브로커를 설정하세요. + 브로커 설정 + 연결됨 + 다시 연결 중… + 코드 복사 + 복사됨! + 공유 + 참여자(%1$d) + 호스트 + 리스너 + 리스너를 기다리는 중… + 내보내기 + 차단 + 관리 + 방 나가기 + 연결할 수 없어요 + 닫기 + 잼 세션을 만들지 못했어요 + 잼 세션에 참여하지 못했어요 + 영문자와 숫자로 된 6자리 방 코드를 입력하세요. + 내 Spotube 잼에 참여하세요. 코드: %1$s + + 앨범으로 이동 + 취침 타이머 + 트랙 세부정보 + 재생 + 일시정지 + 재생 중인 항목 없음 + 대기열에 추가 + + 무시 + 업데이트 + Spotube %1$s 버전을 사용할 수 있어요 + 이번 릴리스의 새로운 기능은 Spotube 다운로드 페이지에서 확인하세요. + %1$s %2$s 버전을 사용할 수 있어요 + 현재 설치된 버전: %1$s + 이번 업데이트의 새로운 기능은 플러그인 페이지에서 확인하세요. + + 건너뛰기 + 다음 + 뒤로 + 시작하기 + Spotube + 아무런 조건 없는 음악. + 음악의 세계를 위한 오픈 소스 플레이어.\n마음껏 탐색하고, 나만의 방식으로 꾸며 보세요. + 이 프로젝트가 계속될 수 있도록 도와주세요. + Spotube는 구독 비즈니스가 아닌 사람들이 만들어요. Spotube가 일상의 일부가 되었다면, 작은 후원이 Spotube를 더 정성껏 가꾸는 데 큰 힘이 돼요.\n\n독립 소프트웨어에 자리를 내어 주셔서 감사합니다. + 먼저 카탈로그 플러그인으로 노래와 플레이리스트를 찾으세요. 재생하려면 오디오 플러그인을 선택하세요. 가사와 감상 기록은 선택 사항이에요. 아래에서 플러그인을 찾아 권한을 확인하고 설치한 다음 여기서 선택하세요. + 플러그인 둘러보기 + 내 소스 + 다시 시도 + 더 많은 플러그인 + 새 플러그인을 찾지 못했어요. 위에서 링크나 .smplug 파일로 설치할 수도 있어요. + 지금은 플러그인 둘러보기를 사용할 수 없어요. 다시 시도하거나 링크 또는 파일로 설치하세요. + 환영합니다 + 후원 + 플러그인 + 가이드 + + Spotube 사용 방법 + 간단한 네 단계면 바로 감상할 수 있어요. + 로그인 + Spotify 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요. + 검색하고 재생하기 + 노래, 앨범, 아티스트를 찾은 다음 재생을 누르세요. + 오프라인으로 감상하기 + 트랙을 다운로드하면 인터넷 연결 없이도 계속 재생할 수 있어요. + 가사 따라가기 + 트랙이 재생되는 동안 플레이어에서 싱크 가사를 열어 보세요. + + + 나의 음악. 나의 소스. 나의 규칙. + 음악을 사랑하는 커뮤니티가 만든 무료 오픈 소스 음악 플레이어. + 오픈 소스 + 광고 없음 + 원격 측정 없음 + 플러그인 기반 + 시작해요 + + + 후원자 덕분에 계속할 수 있어요 + 열정으로 만들고, 여러분 덕분에 이어 갑니다. + Spotube는 100% 무료이자 자유 오픈 소스 소프트웨어예요 — 광고도, 구독도, 추적도 없어요. 모든 기능은 여러분 같은 분들이 코드가 계속 이어지도록 도와주신 덕분에 존재해요. + Patreon + 월간 후원 + 지속적인 개발과 인프라를 지원해 주세요 + Buy Me a Coffee + 커피 한 잔 후원 + 간단한 팁으로 늦은 밤 코딩에 힘을 보태 주세요 + Open Collective + 커뮤니티 기금 + 투명하게 커뮤니티가 운영하는 재정 후원 + 열린 음악을 믿어 주셔서 진심으로 감사합니다. + 후원은 전적으로 선택 사항이에요 — 어떤 경우든 Spotube는 계속 무료예요. + 후원하기 + %1$s 열기 + + + Spotube 후원하기 + Spotube 후원하기 + Spotube는 무료이자 자유 오픈 소스 소프트웨어예요 — 광고도, 구독도, 추적도 없어요. Spotube가 즐거움을 준다면 프로젝트 후원을 고려해 주세요. + 이 알림은 설정 → 후원에서 끌 수 있어요. + 나중에 + 후원 알림 + 7일마다 프로젝트 후원 알림을 가볍게 표시해요. + + + 모듈식 구조 + 음악을 어디서 가져올지 선택하세요. + Spotube는 카탈로그 탐색, 오디오 스트리밍, 싱크 가사를 분리해서 처리해요. 아래에서 엔진을 구성하거나 언제든지 바꿀 수 있어요. + 메타데이터 엔진 + 음악 탐색, 검색, 트랙 목록, 플레이리스트를 담당해요. + 오디오 엔진 + 재생할 오디오 스트림을 가져오고 디코딩해요. + 가사 엔진 + 시간에 맞춘 노래방 가사를 실시간으로 보여 줘요. + 스크로블 엔진 + 감상 기록을 Last.fm 또는 ListenBrainz에 기록해요. + 사용 중 + 기본 제공 + 준비됨 + 선택 사항 + 엔진 선택 + 설치된 엔진 + 기본값으로 설정 + NewPipe YouTube와 LRCLib Lyrics는 기본 제공되며 바로 스트리밍할 수 있어요. + 설정 → 플러그인에서 언제든지 커뮤니티 플러그인을 관리하거나 추가할 수 있어요. + + + 레트로 모던 아키텍처 + 순수한 하이파이, 중개자는 없어요 + Spotube가 기기에서 직접 분산형 재생을 처리하는 방법은 다음과 같아요. + P2P 직접 연결 • 256 KBPS OPUS + 블루 아워 + Spotube · 플레이어 데모 + 그 사이의 순간들을 위한 작은 음악. + 1. 분산형 탐색 + 검색어는 선택한 카탈로그 플러그인으로 바로 전달돼요. Spotube는 취향을 분석하지 않아요. + 2. 직접 스트림 확인 + 오디오는 콘텐츠 서버에서 기기로 바로 스트리밍돼요. 프록시 경유나 원격 데이터 수집은 없어요. + 3. 로컬 DSP & 재생 + 이퀄라이저 컨트롤과 선택적 오프라인 다운로드를 지원하며 기기에서 디코딩하고 캐시해요. + 4. 싱크 가사 & 잼 + 노래방 가사가 재생 시간에 맞춰 흐르고, LAN 원격 제어와 그룹 잼도 지원해요. + 중개 서버가 없어요. 재생은 기기에서 직접 이루어져요 — 설계부터 개인정보를 보호해요. + + + 기기 선택 + %1$s: “%2$s” + 재생 + 대기열에 추가 + 다음에 재생 + 이 기기 + 이 기기에서 + 원격 기기 + 원격 기기(%1$s) + 연결된 기기에서 + + + 연결 실패 + 연결 끊김 + 전송 실패 + 기기 검색 실패: %1$s + 연결하지 못했어요: %1$s + + + 기기 + 새로고침 + %1$s:%2$d에 연결됨 + 연결 해제 + 연결 중... + 네트워크에서 기기를 찾는 중... + 기기를 찾을 수 없어요 + 상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요. + 알 수 없는 기기 + 확인 중... + ID: %1$s... + + + 원격 제어 + 연결 끊김 + 연결 오류: %1$s + 앨범 커버 + 알 수 없는 트랙 + 알 수 없는 아티스트 + 탐색 + 셔플 + 이전 + 다음 + 반복 모드 + 볼륨 + 대기열에서 삭제 + 옵션 더보기 + + + 어디에서 재생할까요? + 진행 중인 잼 세션이 있어요. 이 컬렉션을 재생할 위치를 선택하세요. + 여기에서 재생 + 잼에 추천하기 + + + 구성된 잼 브로커가 없어요 + 브로커 호스트가 비어 있어요 + 연결 실패 + 연결 끊김 + + + 브로커 + 익명 + + + 잼 대기열에 추가했어요 + + 잼 대기열에 %1$d곡을 추가했어요 + + + + 원격 제어 요청 + “%1$s”에서 이 기기의 재생을 제어하려고 해요. + 거부 + 허용 + 항상 허용 + + + 검색 결과 + 전체 + 트랙 + 사용자 + 사용 가능한 검색 유형이 없어요 + 입력하여 검색하세요 + 입력하여 트랙을 검색하세요 + 검색 결과가 없어요 + 모두 보기 + 검색 중 + 트랙 불러오는 중... + 앨범 불러오는 중... + 아티스트 불러오는 중... + 플레이리스트 불러오는 중... + 사용자 불러오는 중... + 앨범을 찾을 수 없어요 + 아티스트를 찾을 수 없어요 + 플레이리스트를 찾을 수 없어요 + 사용자를 찾을 수 없어요 + 레코드 상자를 뒤지는 중... + 우주를 탐색하는 중... + 스피커 예열하는 중... + 바이닐 먼지 터는 중... + 음악의 신께 여쭤보는 중... + 안테나 맞추는 중... + 레코드를 넘겨보는 중... + 검색 기능을 불러오지 못했어요 + 검색하지 못했어요 + 트랙을 검색하지 못했어요 + 앨범을 검색하지 못했어요 + 아티스트를 검색하지 못했어요 + 플레이리스트를 검색하지 못했어요 + 사용자를 검색하지 못했어요 + + + 둘러보기 + + + 싱크 + 일반 + 싱크 가사가 없어요 + 일반 가사가 없어요 + + + %2$s 컨테이너의 %1$s + %1$d kbps + %1$s kHz • %2$d ch + 선택한 메타데이터 플러그인이 없어요 + + + 뒤로 + 다시 시도 + 대화상자 닫기 + 불러오는 중... + 알 수 없음 + 알 수 없는 오류 + + + 사용자 + 플레이리스트 + + + 정말 죄송합니다 + 지금 문제가 발생했어요. 여러분의 경험은 저희에게 소중하며, 문제를 해결하기 위해 최선을 다하고 있어요. 다시 시도하거나 아래 오류 세부정보를 확인하세요. + 오류 세부정보 + 클립보드에 복사 + 전체 로그 보기 + 전체 오류 로그 + + + 셔플 + 셔플 재생 + 팔로우 + 팔로우 취소 + 플레이리스트 편집 + 만든 사람: %1$s + + + 트랙 옵션 + 잼에 추가 + 라디오 시작 + 다음에 재생 + 다음 곡으로 이동 + 대기열에서 삭제 + 즐겨찾기에서 삭제 + 즐겨찾기에 저장 + 플레이리스트에 추가 + 다운로드 + 공유 + + + 기본 순서 + 제목 + 아티스트 + 앨범 + 재생 시간 + 정렬 + 트랙을 찾을 수 없어요 + 필터 + 일괄 작업 + 모두 다운로드 + %1$d곡 다운로드 + 모두 대기열에 추가 + %1$d곡 대기열에 추가 + 모두 다음에 재생 + %1$d곡 다음에 재생 + 모두 플레이리스트에 추가 + %1$d곡 플레이리스트에 추가 + 모두 잼에 추가 + %1$d곡 잼에 추가 + 외 %1$d명 + + + 불러올 URL이 없어요 + 브라우저 기록에서 뒤로 이동 + 브라우저 기록에서 앞으로 이동 + + + 차단 목록 필터... + 차단한 항목이 없어요 + ‘%1$s’와(과) 일치하는 차단 항목이 없어요 + 트랙 + 트랙 + 차단 목록에 추가 + 차단 목록에서 삭제 + + + 트랙 메타데이터 없음 + 스트림 URL을 확인할 수 없어요 + 스트림 URL을 확인할 수 없어요. 먼저 트랙을 재생해 보세요. + + + 저장한 트랙 + 나 + + + 팔로잉 + 인기 트랙 + 인기 트랙 재생 + 인기 트랙을 대기열에 추가 + 관련 아티스트 + 추천 플레이리스트 + 아티스트 개요를 불러오지 못했어요 + 팔로워 정보 없음 + 팔로워 %1$sK명 + 팔로워 %1$sM명 + 팔로워 %1$sB명 + + 팔로워 %1$d명 + + + + 좋아요 + 좋아요 취소 + 트랙 제목 없음 + 아티스트 없음 + 알 수 없는 앨범 + 탐색 + 볼륨 + 셔플 사용 + 셔플 사용 안 함 + 이전 + 다음 + 재생 또는 일시정지 + 반복 끄기 + 한 곡 반복 + 전체 반복 + 음소거 + 음소거 해제 + 옵션 더보기 + 플레이어 옵션 + 플레이어 접기 + 가사 펼치기 + 지금 재생 중 + 가사 + 대체 소스 + 대체 소스 + 다운로드 + 다운로드 완료 + 다운로드 중... + 이미 다운로드됨 + 다운로드 다시 시도 중... + %1$s 다운로드 중 + %1$s 후 재생이 일시정지돼요. + 선택한 시간(분)이 지나면 재생을 일시정지해요. + 분 + 1~600분 사이에서 선택하세요 + 타이머 취소 + 시작 + 재생 URL이 다른 스트리밍 설정으로 확인되었어요. 강조 표시된 스트림이 현재 설정과 일치하며, 사용 중인 URL은 다음 스트림 요청 시 새로고침돼요. + 일치하는 오디오 소스 + 일치하는 소스 불러오는 중… + 로컬 캐시 파일을 재생하는 동안에는 일치하는 원격 소스가 필요하지 않아요. + 일치하는 소스가 없어요. + 이 트랙에 캐시된 일치 오디오 소스가 없어요. + 스트림 정보가 아직 캐시되지 않았어요. 먼저 트랙을 재생해 불러오세요. + 트랙 세부정보를 불러오지 못했어요. + 사용 가능한 스트림 + 이 트랙은 로컬 캐시에서 재생 중이에요. 이번 재생에는 스트림 변형이 사용되지 않아요. + 스트림 불러오는 중… + 이 소스에 사용 가능한 스트림이 없어요. + 현재 선택된 스트림 + 스트리밍 형식 & 음질 설정과 일치 + 현재 재생 URL + 로컬 캐시에서 재생 중 + 메타데이터 트랙 + 제목 + ID + 앨범 + 일치도 + 외부 URI + 앨범 ID + 앨범 유형 + 앨범 발매일 + 앨범 트랙 수 + 앨범 장르 + 재생 시간 + 트랙 번호 + 디스크 번호 + 청소년 이용불가 + 인기도 + ISRC + 썸네일 URL + + + 사이드바 전환 + + + 대체 소스 + 현재 재생 중인 트랙이 없어요 + 대체 소스를 찾는 중... + 대체 소스를 찾을 수 없어요 + 일치도: %1$d% + 현재 소스 + + + 내보내기 + 세션에서 내보내요 + 내보내고 다시 참여하지 못하게 해요 + 추천곡 삭제 + 이 참여자가 대기열에 추가한 모든 트랙을 삭제해요 + 순서 변경 + 대기열에서 삭제 + %1$s • %2$s님이 추가함 + + + 알 수 없음 + 플레이리스트 불러오는 중... + 트랙 추가 + 플레이리스트에 트랙 추가 + 트랙 검색... + 검색 + 트랙을 찾을 수 없어요 + 추가할 트랙을 검색하세요 + 완료 + 추가 + 추가됨 + + + 앨범 불러오는 중... + + + 내 플레이리스트에 추가 + 검색 + 내 플레이리스트 검색... + 내 플레이리스트 불러오는 중... + 아직 소유한 플레이리스트가 없어요.\n플레이리스트를 만들어 트랙을 추가하세요. + 플레이리스트 만들기 + 플레이리스트 편집 + 만들기 + 이름 + 플레이리스트 이름 + 설명 + 설명(선택 사항) + 공개 + 누구나 이 플레이리스트를 볼 수 있어요 + 공동 작업 + 다른 사람도 이 플레이리스트에 트랙을 추가할 수 있어요 + 커버 이미지 + 플레이리스트 커버 + 이미지 없음 + 이미지 변경 + 이미지 선택 + + + 설치 + 교체 + zip 파일에서 plugin.json을 찾을 수 없어요 + zip 파일에서 manifest.zipline.json을 찾을 수 없어요 + plugin.json 형식이 올바르지 않아요: %1$s + 플러그인을 읽지 못했어요: %1$s + 플러그인을 설치하지 못했어요: %1$s + %1$s의 새 버전을 사용할 수 있어요. 설치하기 전에 업데이트를 확인하세요. + %1$s이(가) 이미 설치되어 있어요. 설치하기 전에 교체를 확인하세요. + 기본 제공 플러그인은 이미 포함되어 있어 다시 추가할 수 없어요. + 기본 제공 플러그인은 삭제할 수 없어요. + %1$s에 잘못된 apiVersion(%2$s)이 선언되어 있어요. %3$s과(와) 호환되는 유의적 버전이 필요해요. + Spotube 플러그인 API 버전 %1$s이(가) 올바르지 않아요. + %1$s은(는) 플러그인 API %2$s용이지만, Spotube는 호환성이 깨지는 플러그인 API %3$s을(를) 지원해요. 같은 주 API 버전용으로 빌드된 플러그인을 설치하세요. + + + 호환되지 않는 플러그인 API + 플러그인을 설치할까요? + %1$s이(가) 설치된 플러그인에 추가돼요. + 플러그인을 업데이트할까요? + 이전 버전(%1$s)이 설치되어 있어요. %2$s(으)로 업데이트할까요? + 설치된 플러그인을 교체할까요? + %1$s은(는) 잘못된 유의적 버전(%2$s)을 사용하므로 업데이트로 비교할 수 없어요. 그래도 설치된 플러그인을 교체할까요? + 설치된 버전(%1$s)은 유의적 버전으로 비교할 수 없어요. %2$s(으)로 교체할까요? + 버전 %1$s이(가) 이미 설치되어 있어요. 기존 플러그인을 제공된 사본으로 교체할까요? + 설치된 버전 %1$s이(가) 제공된 버전 %2$s보다 최신이에요. 그래도 교체할까요? + 설치된 플러그인을 제공된 사본으로 교체할까요? + + + %1$s: + 버전 + API 버전 + 제작자 + 라이선스 + 권한 + 기능 + 저장소 + 연락처 + 버그 신고 + %1$s 후원하기 + + + 공식 + 인증됨 + GitHub 스타 + GitHub 저장소 URL + + + 릴리스를 찾을 수 없어요 + 릴리스 선택 + 릴리스를 선택하세요 + 사전 릴리스 + 릴리스 노트 + + + 홈 + 검색 + 이 기기 + 연결 + 기기 + + + 창 숨기기 + 창 표시 + 다음 트랙 + 이전 트랙 + 셔플 + 반복 + 반복: 끔 + 반복: 한 곡 + 반복: 전체 + 볼륨: %1$d% + 볼륨 + + 볼륨 - + 음소거 + 음소거 해제 + 트랙 좋아요 + 트랙 좋아요 취소 + 종료 + + + 최소화 + 최대화 + 이전 크기로 복원 + 닫기 + + + 재생 서비스 + 재생 + 미디어 재생 컨트롤 + + + 둘러보기 + 지금 재생 중 + 추천 + 저장한 트랙 + 인기 트랙 + diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml new file mode 100644 index 00000000..2c79ad97 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -0,0 +1,938 @@ + + + + Wachtrij + Wachtrij wissen + Wachtrij filteren... + Wachtrij is leeg + + Zoek nummers, artiesten, albums... + Alle geschiedenis wissen + Zoeken... + + + Jouw bibliotheek + Bibliotheek + Playlists + Albums + Artiesten + Lokale nummers + Downloads + Zoek in opgeslagen playlists… + Zoek in opgeslagen albums… + Zoek in opgeslagen artiesten… + Zoek mappen of nummers… + Bibliotheek filteren + Filter sluiten + Playlist maken + Lokale nummers vernieuwen + Rasterweergave + Lijstweergave + Geen opgeslagen playlists + Geen playlists gevonden voor “%1$s” + Geen opgeslagen albums + Geen albums gevonden voor “%1$s” + Geen opgeslagen artiesten + Geen artiesten gevonden voor “%1$s” + + + Mappen + Map + %1$s • %2$s + + %1$d nummer + %1$d nummers + + + %1$d map + %1$d mappen + + Terug naar mappen + Map afspelen + Toegang tot audio vereist + Geef toegang tot audio om muziek op je apparaat te bekijken. + Toegang geven + Geen lokale mappen gevonden + Geen nummers in deze map + Geen overeenkomende mappen of nummers + Lokale media vernieuwen mislukt + + + %1$d actief • %2$d voltooid + Voltooide wissen + Nog geen downloads + Downloaden %1$d% + In wachtrij + Voltooid + Geannuleerd + Mislukt + Mislukt: %1$s + Download annuleren + Download opnieuw proberen + Download verwijderen + + + Artiest + Op zwarte lijst + Gelikete nummers + Je opgeslagen nummers + Onbekende artiest + Titel van nummer + Naam van artiest + + Geselecteerd + + Instellingen + + Plug-ins + Taal en regio + Weergave + Afspelen + Downloads + Cache + Desktop + Updates + Steunen + Over + + Sluiten + Opslaan + Annuleren + + Taal + Taal van de app. Huidig: %1$s + Systeemstandaard + Land + Land voor content en beschikbaarheid. Huidig: %1$s + %1$s (%2$s) + + Thema + Kies hoe Spotube aansluit op de weergave van je systeem. Huidig: %1$s + Licht + Donker + Systeem + + Oppervlak + Achtergrondmateriaal van de app. Huidig: %1$s + Waterig + IJzig + Chroom + Acryl + Glas + + Accentkleur + Huidig palet: %1$s + Accentkleur + Kies een accentpalet en bekijk het in zowel het lichte als het donkere thema. + Licht + Donker + Voorbeeld + Knop + Groene kobold + Elektrisch violet + Oceaancyaan + Zonsondergangoranje + Rozentuin + Middernachtblauw + Metaalgrijs + + Uiterlijk + Oppervlaktemateriaal en accent. Huidig: %1$s · %2$s + Uiterlijk + Kies een oppervlaktemateriaal en daarna een accent dat erbij past. Elke optie is een zorgvuldig gekozen combinatie. + Oppervlak + Accent + Secundair + + Palet + Millennium-accentpalet. Huidig: %1$s + Palet + Kies een Millennium-accentpalet en bekijk het in licht en donker. + Palet + Millennium-palet en accent. Huidig: %1$s + Blauw + Oranje + Limoen + + Andere downloadmap + Huidige map: %1$s + Huidige map: standaard muziekmap + Laat dit leeg om de standaard muziekmap te gebruiken. + C:\Music\Spotube + Lokale mediamappen + Eigen mappen om te scannen op desktop. Toegevoegd: %1$d + Nog geen eigen map toegevoegd + Eigen mappen voor je lokale muziekbibliotheek. + Beheren + Map toevoegen + Map verwijderen + Geen eigen mappen toegevoegd. + %1$s verwijderen uit de scanlijst + De download- en muziekcachemappen worden automatisch meegenomen. + Je downloadmap wordt automatisch meegenomen. + De muziekcachemap wordt automatisch meegenomen. + Opslaan… + Kan mappen niet opslaan. Je wijzigingen zijn bewaard; probeer het opnieuw. + + %1$d map + %1$d mappen + + Downloadformaat + Voorkeurscodec en -container voor downloads. Huidig: %1$s + Downloadkwaliteit + + Streamingformaat + Voorkeurscodec en -container voor het streamen van muziek. Huidig: %1$s + Streamingkwaliteit + Huidig: %1$s + Muziekcache inschakelen + Gestreamde audio lokaal opslaan zodat herhaald afspelen sneller gaat. + Cachemap + Huidig: %1$s + Huidig: standaard app-cache + Maximale cachegrootte + Geen limiet + Huidige limiet: %1$d MB + Stel de maximale cachegrootte in MB in. Vul 0 in of laat leeg voor geen limiet. + 500 + Grootte moet 0 of een positief getal zijn. + Eindeloos afspelen inschakelen + Blijf afspelen met aanbevolen nummers als je wachtrij leeg is. + Connect inschakelen + Maak bediening op afstand beschikbaar via Spotube Connect. + Poort van afspeelproxyserver + Bediening op afstand toestaan + Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken. + Apparaatnaam voor bediening op afstand + De naam die andere apparaten zien als ze dit apparaat vinden. Huidig: %1$s + Kies een herkenbare naam die andere apparaten op je netwerk te zien krijgen als ze deze Spotube-installatie vinden. + bijv. Spotube woonkamer + hostnaam van apparaat + Poort die de afspeelproxyserver gebruikt. Huidig: %1$d + Kies een poort tussen 1 en 65535. + 14769 + Voer een geldig geheel getal in. + Poort moet tussen 1 en 65535 liggen. + + Zwarte lijst + Nummers en artiesten op de zwarte lijst beheren + + Minimaliseren naar systeemvak + Houd Spotube actief in het systeemvak als het venster wordt geminimaliseerd. + Discord Rich Presence + Toon wat je afspeelt in Discord op desktop. + + Automatisch controleren op updates + Op de achtergrond zoeken naar nieuwe Spotube-versies. + + Plug-ins beheren + Beheer je plug-ins + Standaardplug-in voor %1$s + Metadata + Audio + Songteksten + Scrobbelen + Geen plug-in geselecteerd + Wijzigen + Selecteren + Geselecteerd + Selectie wissen + Geen plug-ins beschikbaar + Plug-in %1$s + Wissen + + Configureren + Plug-inbeheer + Voer een URL in + URL moet beginnen met http:// of https:// + Plug-in downloaden mislukt + Nog geen plug-ins geïnstalleerd + Plak hierboven een download-URL of kies een bestand. + + %1$d plug-in geïnstalleerd + %1$d plug-ins geïnstalleerd + + Plug-in installeren + https://example.com/plugin.smplug + Downloaden + Installeren uit bestand (.smplug) + Actief + v%1$s + Ingebouwd + Plug-in verwijderen + Inloggen + Uitloggen + Downloaden via URL + Installeren uit bestand + Installeren + + • %1$s + Gevraagde machtigingen + Deze plug-in heeft geen speciale machtigingen nodig. + Geïnstalleerd vs. aangeboden + Geïnstalleerd + Aangeboden + API %1$s → %2$s + Permanente opslag + Gegevens lezen van en schrijven naar schijf + Netwerkverzoeken + Gegevens verzenden en ontvangen via internet + WebView + Webcontent weergeven in de app + Group Jam + MQTT-broker + Broker-host + %1$s:%2$d + Broker-poort + TLS gebruiken + Gebruikersnaam (optioneel) + Wachtwoord (optioneel) + Voorvoegsel client-ID + Wordt gebruikt om deze Spotube-installatie te herkennen + De verbinding met de broker versleutelen + Controleer de huidige broker-instellingen voordat je een Jam start. + Verbinding testen + Testen… + Verbonden in %1$d ms + Mislukt: %1$s + Tijdelijke broker — stel je eigen broker in om zelf te hosten + Verbinding + Inloggegevens + Identiteit + Diagnose + + + Group Jam + Maken + Deelnemen + Maak een sessie en deel de code met je vrienden. + Sessie maken + Deelnemen aan sessie + Verbinden… + Sessiecode + ABC123 + Broker: %1$s + Stel een broker in bij Instellingen om een sessie te hosten of eraan deel te nemen. + Broker-instellingen + Verbonden + Opnieuw verbinden… + Code kopiëren + Gekopieerd! + Delen + Deelnemers (%1$d) + Host + Luisteraar + Wachten op luisteraars… + Verwijderen + Blokkeren + Beheren + Sessie verlaten + Kan geen verbinding maken + Sluiten + Jam-sessie maken mislukt + Deelnemen aan Jam-sessie mislukt + Voer een sessiecode van zes tekens in met letters en cijfers. + Doe mee met mijn Spotube Jam met code: %1$s + + Naar album + Slaaptimer + Details van nummer + Afspelen + Pauzeren + Er wordt niets afgespeeld + Toevoegen aan wachtrij + + Negeren + Bijwerken + Spotube %1$s is beschikbaar + Bekijk wat er nieuw is in deze versie op de downloadpagina van Spotube. + %1$s %2$s is beschikbaar + Momenteel geïnstalleerd: %1$s + Bekijk wat er nieuw is in deze update op de pagina van de plug-in. + + Overslaan + Volgende + Terug + Aan de slag + Spotube + Muziek, zonder verplichtingen. + Een opensource-speler voor een wereld vol muziek.\nOm te ontdekken. Om helemaal van jou te maken. + Houd dit project in leven. + Spotube wordt gemaakt door mensen, niet door een abonnementsbedrijf. Als het onderdeel wordt van je dagelijks leven, helpt een beetje steun ons om het de zorg te geven die het verdient.\n\nBedankt dat je ruimte maakt voor onafhankelijke software. + Begin met een catalogusplug-in om nummers en playlists te vinden. Kies een audioplug-in om ze af te spelen. Songteksten en luistergeschiedenis zijn optioneel. Ontdek hieronder een plug-in, bekijk de machtigingen, installeer hem en selecteer hem hier. + Plug-ins ontdekken + Je bronnen + Opnieuw proberen + Meer plug-ins + Geen nieuwe plug-ins gevonden. Je kunt er ook hierboven een installeren via een link of een .smplug-bestand. + Plug-ins ontdekken is nu niet beschikbaar. Probeer het opnieuw of installeer via een link of bestand. + Welkom + Steunen + Plug-ins + Uitleg + + Zo gebruik je Spotube + Vier snelle stappen en je luistert al. + Inloggen + Koppel je Spotify-account of kies je eigen catalogusplug-in. + Zoeken en afspelen + Vind elk nummer, album of elke artiest en druk op afspelen. + Offline luisteren + Download nummers om ook zonder verbinding te luisteren. + Volg de songtekst + Open gesynchroniseerde songteksten in de speler terwijl een nummer speelt. + + + Jouw muziek. Jouw bronnen. Jouw regels. + Een gratis opensource-muziekspeler, gemaakt door een community die gewoon van muziek houdt. + Opensource + Geen advertenties + Geen telemetrie + Werkt met plug-ins + Aan de slag + + + Supporters houden ons op gang + Gemaakt met passie. In leven gehouden door jou. + Spotube is 100% gratis, vrij en opensource — geen advertenties, geen abonnementen, geen tracking. Elke functie bestaat omdat mensen zoals jij hebben geholpen de code te laten stromen. + Patreon + Maandelijkse sponsor + Steun doorlopende ontwikkeling en infrastructuur + Buy Me a Coffee + Eenmalig koffie + Geef een nachtelijke codeersessie een boost met een kleine fooi + Open Collective + Communityfonds + Transparante financiële steun, beheerd door de community + Uit de grond van ons hart bedankt dat je gelooft in open muziek. + Steunen is helemaal optioneel — Spotube blijft hoe dan ook gratis. + Sponsoren + %1$s openen + + + Spotube steunen + Spotube steunen + Spotube is gratis, vrij en opensource — geen advertenties, geen abonnementen, geen tracking. Als je er blij van wordt, overweeg dan het project te steunen. + Je kunt deze herinneringen uitzetten via Instellingen → Steunen. + Misschien later + Herinneringen om te steunen + Toon elke 7 dagen een vriendelijke herinnering om het project te steunen. + + + Modulaire architectuur + Kies waar je muziek vandaan komt. + Spotube scheidt het ontdekken van de catalogus, het streamen van audio en gesynchroniseerde songteksten. Stel je engines hieronder in of wissel ze op elk moment. + Metadata-engine + Zorgt voor muziek ontdekken, zoeken, tracklijsten en playlists. + Audio-engine + Haalt de audiostreams op die worden afgespeeld en decodeert ze. + Songtekst-engine + Streamt tijdgesynchroniseerde karaokesongteksten in realtime. + Scrobble-engine + Legt je luistergeschiedenis vast op Last.fm of ListenBrainz. + Actief + Ingebouwd + Klaar + Optioneel + Kies een engine + Geïnstalleerde engines + Instellen als standaard + NewPipe YouTube en LRCLib Lyrics zijn ingebouwd en direct klaar om te streamen. + Beheer of voeg op elk moment communityplug-ins toe via Instellingen → Plug-ins. + + + Retromoderne architectuur + Pure hi-fi, geen tussenpersonen + Zo regelt Spotube gedecentraliseerd afspelen, rechtstreeks op je apparaat. + RECHTSTREEKS PEER-TO-PEER • 256 KBPS OPUS + Blauw uur + Spotube · demonstratie van de speler + Een beetje muziek voor de momenten tussendoor. + 1. Gedecentraliseerd ontdekken + Zoekopdrachten gaan rechtstreeks naar je gekozen catalogusplug-in. Spotube maakt nooit een profiel van je smaak. + 2. Directe streams + Audio wordt rechtstreeks van de contentservers naar je apparaat gestreamd. Geen proxy\'s ertussen en geen gegevensverzameling op afstand. + 3. Lokale DSP & afspelen + Lokaal gedecodeerd en gecachet, met equalizer en optioneel offline downloaden. + 4. Gesynchroniseerde songteksten & Jam + Karaokesongteksten volgen de afspeelpositie, met bediening op afstand via LAN en Group Jam. + Geen tussenservers. Afspelen gebeurt gewoon op je apparaat — privé vanaf het ontwerp. + + + Kies een apparaat + %1$s: ‘%2$s’ + Afspelen + Toevoegen aan wachtrij + Hierna afspelen + Dit apparaat + Op dit apparaat + Extern apparaat + Extern apparaat (%1$s) + Op het verbonden apparaat + + + Verbinding mislukt + Verbinding verbroken + Verzenden mislukt + Zoeken mislukt: %1$s + Verbinden mislukt: %1$s + + + Apparaten + Vernieuwen + Verbonden met %1$s:%2$d + Verbinding verbreken + Verbinden... + Zoeken naar apparaten op het netwerk... + Geen apparaten gevonden + Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat. + Onbekend apparaat + Bezig met ophalen... + ID: %1$s... + + + Bediening op afstand + Niet verbonden + Verbindingsfout: %1$s + Albumhoes + Onbekend nummer + Onbekende artiest + Zoeken in nummer + Shuffle + Vorige + Volgende + Herhaalmodus + Volume + Verwijderen uit wachtrij + Meer opties + + + Waar afspelen? + Je hebt een actieve Jam-sessie. Kies waar je deze collectie wilt afspelen. + Hier afspelen + Voorstellen aan Jam + + + Geen Jam-broker ingesteld + Broker-host is leeg + Verbinding mislukt + Verbinding verbroken + + + Broker + anoniem + + + Toegevoegd aan de Jam-wachtrij + + %1$d nummer toegevoegd aan de Jam-wachtrij + %1$d nummers toegevoegd aan de Jam-wachtrij + + + + Verzoek voor bediening op afstand + \"%1$s\" wil het afspelen op dit apparaat bedienen. + Weigeren + Toestaan + Altijd toestaan + + + Zoekresultaten + Alles + Nummers + Gebruikers + Geen zoektypen beschikbaar + Begin met typen om te zoeken + Begin met typen om nummers te zoeken + Geen resultaten gevonden + Alles weergeven + Zoeken + Nummers laden... + Albums laden... + Artiesten laden... + Playlists laden... + Gebruikers laden... + Geen albums gevonden + Geen artiesten gevonden + Geen playlists gevonden + Geen gebruikers gevonden + Door de platenbakken aan het spitten... + De kosmos aan het doorzoeken... + De speakers aan het opwarmen... + Het vinyl aan het afstoffen... + De muziekgoden aan het raadplegen... + De antennes aan het afstemmen... + Door de platen aan het bladeren... + Zoekmogelijkheden laden mislukt + Zoeken mislukt + Nummers zoeken mislukt + Albums zoeken mislukt + Artiesten zoeken mislukt + Playlists zoeken mislukt + Gebruikers zoeken mislukt + + + Ontdekken + + + Gesynchroniseerd + Platte tekst + Geen gesynchroniseerde songtekst beschikbaar + Geen songtekst beschikbaar + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d ch + Geen metadataplug-in geselecteerd + + + Terug + Opnieuw proberen + Dialoogvenster sluiten + Laden... + Onbekend + Onbekende fout + + + Gebruiker + Playlist + + + Het spijt ons echt + Er werkt momenteel iets niet goed. Jouw ervaring is belangrijk voor ons en we doen ons best om dit op te lossen. Probeer het opnieuw of bekijk de foutdetails hieronder. + Foutdetails + Kopiëren naar klembord + Volledig logboek bekijken + Volledig foutenlogboek + + + Shuffle + Shuffle afspelen + Volgen + Ontvolgen + Playlist bewerken + Van %1$s + + + Opties voor nummer + Toevoegen aan Jam + Radio starten + Hierna afspelen + Naar volgende verplaatsen + Verwijderen uit wachtrij + Verwijderen uit favorieten + Opslaan als favoriet + Toevoegen aan playlist + Downloaden + Delen + + + Oorspronkelijk + Titel + Artiest + Album + Duur + Sorteren + Geen nummers gevonden + Filteren + Bulkacties + Alles downloaden + %1$d downloaden + Alles toevoegen aan wachtrij + %1$d toevoegen aan wachtrij + Alles hierna afspelen + %1$d hierna afspelen + Alles toevoegen aan playlist + %1$d toevoegen aan playlist + Alles toevoegen aan Jam + %1$d toevoegen aan Jam + +%1$d meer + + + Geen URL om te laden + Terug in browsergeschiedenis + Vooruit in browsergeschiedenis + + + Zwarte lijst filteren... + Niets op de zwarte lijst + Niets op de zwarte lijst komt overeen met \'%1$s\' + Nummers + Nummer + Toevoegen aan zwarte lijst + Verwijderen van zwarte lijst + + + Geen metadata voor nummer + Kan stream-URL niet ophalen + Kan stream-URL niet ophalen. Speel het nummer eerst af. + + + Opgeslagen nummers + Jij + + + Volgend + Populairste nummers + Populairste nummers afspelen + Populairste nummers toevoegen aan wachtrij + Vergelijkbare artiesten + Uitgelichte playlists + Artiestoverzicht laden mislukt + Volgers niet beschikbaar + %1$sK volgers + %1$s mln. volgers + %1$s mld. volgers + + %1$d volger + %1$d volgers + + + + Liken + Niet meer liken + Titel van no-op-nummer + No-op-artiesten + Onbekend album + Zoeken in nummer + Volume + Shuffle aanzetten + Shuffle uitzetten + Vorige + Volgende + Afspelen of pauzeren + Herhalen uit + Eén nummer herhalen + Alles herhalen + Dempen + Dempen opheffen + Meer opties + Speleropties + Speler inklappen + Songtekst uitvouwen + Nu aan het afspelen + Songtekst + Alternatieve bron + Alternatieve bron + Downloaden + Gedownload + Downloaden... + Al gedownload + Download opnieuw proberen... + %1$s downloaden + Afspelen pauzeert over %1$s. + Pauzeer het afspelen na het gekozen aantal minuten. + Minuten + Kies tussen 1 en 600 minuten + Timer annuleren + Starten + De afspeel-URL is opgehaald met andere streaminginstellingen. De gemarkeerde stream komt overeen met je huidige voorkeur; de actieve URL wordt vernieuwd bij het volgende streamverzoek. + Gekoppelde audiobron + Gekoppelde bron laden… + Een gekoppelde externe bron is niet nodig zolang het lokale cachebestand wordt afgespeeld. + Er is geen gekoppelde bron beschikbaar. + Er is geen gekoppelde audiobron gecachet voor dit nummer. + Streamdetails zijn nog niet gecachet. Speel het nummer eerst af om ze te laden. + Details van nummer laden mislukt. + Beschikbare streams + Dit nummer wordt afgespeeld vanuit de lokale cache. Streamvarianten worden voor deze weergave niet gebruikt. + Streams laden… + Er zijn geen streams beschikbaar voor deze bron. + Momenteel geselecteerde stream + Komt overeen met je voorkeur voor streamingformaat & -kwaliteit + Actieve afspeel-URL + Afgespeeld vanuit lokale cache + Metadata van nummer + Titel + ID + Album + Betrouwbaarheid + Externe URI + Album-ID + Albumtype + Releasedatum album + Aantal nummers op album + Albumgenres + Duur + Nummer op album + Schijfnummer + Expliciet + Populariteit + ISRC + Miniatuur-URL\'s + + + Zijbalk tonen/verbergen + + + Alternatieve bronnen + Er wordt nu geen nummer afgespeeld + Zoeken naar alternatieve bronnen... + Geen alternatieve bronnen gevonden + Betrouwbaarheid: %1$d% + Actieve bron + + + Verwijderen + Verwijder deze persoon uit de sessie + Verwijderen en voorkomen dat deze persoon opnieuw deelneemt + Suggesties verwijderen + Verwijder alle nummers die deze persoon aan de wachtrij heeft toegevoegd + Volgorde wijzigen + Verwijderen uit wachtrij + %1$s • Toegevoegd door %2$s + + + Onbekend + Playlist laden... + Nummers toevoegen + Nummers toevoegen aan playlist + Nummers zoeken... + Zoeken + Geen nummers gevonden + Zoek nummers om toe te voegen + Klaar + Toevoegen + Toegevoegd + + + Album laden... + + + Toevoegen aan je playlist + Zoeken + Zoek in je playlists... + Je playlists laden... + Je hebt nog geen eigen playlists.\nMaak er een om nummers toe te voegen. + Playlist maken + Playlist bewerken + Maken + Naam + Naam van playlist + Beschrijving + Beschrijving (optioneel) + Openbaar + Iedereen kan deze playlist zien + Gezamenlijk + Anderen kunnen nummers aan deze playlist toevoegen + Coverafbeelding + Playlistcover + Geen afbeelding + Afbeelding wijzigen + Afbeelding kiezen + + + Installeren + Vervangen + plugin.json niet gevonden in het zip-bestand + manifest.zipline.json niet gevonden in het zip-bestand + Ongeldige indeling van plugin.json: %1$s + Plug-in lezen mislukt: %1$s + Plug-in installeren mislukt: %1$s + Er is een nieuwere versie van %1$s beschikbaar. Bevestig de update voordat je installeert. + %1$s is al geïnstalleerd. Bevestig het vervangen voordat je installeert. + Ingebouwde plug-ins zijn al inbegrepen en kunnen niet opnieuw worden toegevoegd. + Ingebouwde plug-ins kunnen niet worden verwijderd. + %1$s geeft een ongeldige apiVersion op (%2$s). Verwacht: semantische versie compatibel met %3$s. + Plug-in-API-versie %1$s van Spotube is ongeldig. + %1$s is gemaakt voor plug-in-API %2$s, maar Spotube ondersteunt de breaking plug-in-API %3$s. Installeer een plug-in die is gemaakt voor dezelfde breaking API-versie. + + + Plug-in-API niet compatibel + Plug-in installeren? + %1$s wordt toegevoegd aan je geïnstalleerde plug-ins. + Plug-in bijwerken? + Er is een oudere versie geïnstalleerd (%1$s). Bijwerken naar %2$s? + Geïnstalleerde plug-in vervangen? + %1$s gebruikt een ongeldige semantische versie (%2$s) en kan niet als update worden vergeleken. De geïnstalleerde plug-in toch vervangen? + De geïnstalleerde versie (%1$s) kan niet worden vergeleken met semantische versies. Vervangen door %2$s? + Versie %1$s is al geïnstalleerd. De bestaande plug-in vervangen door de aangeboden kopie? + De geïnstalleerde versie %1$s is nieuwer dan de aangeboden versie %2$s. Toch vervangen? + De geïnstalleerde plug-in vervangen door de aangeboden kopie? + + + %1$s: + Versie + API-versie + Auteur + Licentie + Mogelijkheden + Functies + Repository + Contact + Bugs melden + %1$s steunen + + + Officieel + Geverifieerd + GitHub-sterren + URL van GitHub-repository + + + Geen releases gevonden + Release kiezen + Kies een release + Pre-release + Release-opmerkingen + + + Home + Zoeken + Op apparaat + Verbinden + Apparaten + + + Venster verbergen + Venster tonen + Volgend nummer + Vorig nummer + Shuffle + Herhalen + Herhalen: UIT + Herhalen: ÉÉN + Herhalen: ALLES + Volume: %1$d% + Volume + + Volume - + Dempen + Dempen opheffen + Nummer liken + Nummer niet meer liken + Afsluiten + + + Minimaliseren + Maximaliseren + Herstellen + Sluiten + + + Afspeelservice + Afspelen + Bediening voor media-afspelen + + + Ontdekken + Nu aan het afspelen + Uitgelicht + Opgeslagen nummers + Populairste nummers + diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml new file mode 100644 index 00000000..ddc97d41 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -0,0 +1,950 @@ + + + + Kolejka + Wyczyść kolejkę + Filtruj kolejkę... + Kolejka jest pusta + + Szukaj utworów, wykonawców, albumów... + Wyczyść całą historię + Szukaj... + + + Twoja biblioteka + Biblioteka + Playlisty + Albumy + Wykonawcy + Lokalne utwory + Pobrane + Szukaj w zapisanych playlistach… + Szukaj w zapisanych albumach… + Szukaj wśród zapisanych wykonawców… + Szukaj folderów lub utworów… + Filtruj bibliotekę + Zamknij filtr + Utwórz playlistę + Odśwież lokalne utwory + Przełącz na widok siatki + Przełącz na widok listy + Brak zapisanych playlist + Brak playlist pasujących do „%1$s” + Brak zapisanych albumów + Brak albumów pasujących do „%1$s” + Brak zapisanych wykonawców + Brak wykonawców pasujących do „%1$s” + + + Foldery + Folder + %1$s • %2$s + + %1$d utwór + %1$d utwory + %1$d utworów + %1$d utworu + + + %1$d folder + %1$d foldery + %1$d folderów + %1$d folderu + + Wróć do folderów + Odtwórz folder + Wymagany dostęp do audio + Zezwól na dostęp do audio, aby przeglądać muzykę na urządzeniu. + Zezwól na dostęp + Nie znaleziono lokalnych folderów + Brak utworów w tym folderze + Brak pasujących folderów lub utworów + Nie udało się odświeżyć lokalnych multimediów + + + Aktywne: %1$d • Ukończone: %2$d + Wyczyść ukończone + Nic jeszcze nie pobrano + Pobieranie %1$d% + W kolejce + Ukończono + Anulowano + Niepowodzenie + Niepowodzenie: %1$s + Anuluj pobieranie + Ponów pobieranie + Usuń pobrany plik + + + Wykonawca + Na czarnej liście + Polubione utwory + Twoje zapisane utwory + Nieznany wykonawca + Tytuł utworu + Nazwa wykonawcy + + Zaznaczono + + Ustawienia + + Wtyczki + Język i region + Wygląd + Odtwarzanie + Pobieranie + Pamięć podręczna + Komputer + Aktualizacje + Wsparcie + Informacje + + Zamknij + Zapisz + Anuluj + + Język + Język interfejsu aplikacji. Obecnie: %1$s + Domyślny systemowy + Kraj + Kraj regionu dla treści i dostępności. Obecnie: %1$s + %1$s (%2$s) + + Motyw + Wybierz, jak Spotube dopasowuje się do wyglądu systemu. Obecnie: %1$s + Jasny + Ciemny + Systemowy + + Powierzchnia + Materiał tła aplikacji. Obecnie: %1$s + Wodny + Lodowy + Chrom + Akryl + Szkło + + Kolor akcentu + Obecna paleta: %1$s + Kolor akcentu + Wybierz paletę akcentu i zobacz jej podgląd w jasnym i ciemnym motywie. + Jasny + Ciemny + Podgląd + Przycisk + Zielony goblin + Elektryczny fiolet + Oceaniczny cyjan + Pomarańcz zachodu + Różany ogród + Granat północy + Metaliczny łupek + + Wygląd i styl + Materiał powierzchni i akcent. Obecnie: %1$s · %2$s + Wygląd i styl + Wybierz materiał powierzchni, a następnie pasujący do niego akcent. Każda opcja to starannie dobrane połączenie. + Powierzchnia + Akcent + Dodatkowy + + Paleta + Paleta akcentów Millennium. Obecnie: %1$s + Paleta + Wybierz paletę akcentów Millennium i zobacz jej podgląd w jasnym i ciemnym motywie. + Paleta + Paleta i akcent Millennium. Obecnie: %1$s + Niebieska + Pomarańczowa + Limonkowa + + Własny folder pobierania + Obecny folder: %1$s + Obecny folder: domyślny folder muzyki + Pozostaw puste, aby używać domyślnego folderu muzyki. + C:\Music\Spotube + Foldery lokalnych multimediów + Własne foldery skanowane na komputerze. Dodano: %1$d + Nie dodano jeszcze własnego folderu + Własne foldery dla lokalnej biblioteki muzyki. + Zarządzaj + Dodaj folder + Usuń folder + Nie dodano własnych folderów. + Usuń %1$s z listy skanowania + Foldery pobierania i pamięci podręcznej muzyki są uwzględniane automatycznie. + Folder pobierania jest uwzględniany automatycznie. + Folder pamięci podręcznej muzyki jest uwzględniany automatycznie. + Zapisywanie… + Nie udało się zapisać folderów. Twoje zmiany nadal tu są – spróbuj ponownie. + + %1$d folder + %1$d foldery + %1$d folderów + %1$d folderu + + Format pobieranej muzyki + Preferowany kodek i kontener pobierania. Obecnie: %1$s + Jakość pobieranej muzyki + + Format strumieniowanej muzyki + Preferowany kodek i kontener strumieniowania muzyki. Obecnie: %1$s + Jakość strumieniowanej muzyki + Obecnie: %1$s + Włącz pamięć podręczną muzyki + Zapisuj strumieniowany dźwięk lokalnie, aby przyspieszyć ponowne odtwarzanie. + Folder pamięci podręcznej + Obecnie: %1$s + Obecnie: domyślna pamięć podręczna aplikacji + Limit pamięci podręcznej + Bez limitu + Obecny limit: %1$d MB + Ustaw maksymalny rozmiar pamięci podręcznej w MB. Wpisz 0 lub pozostaw puste, aby nie ustawiać limitu. + 500 + Rozmiar musi wynosić 0 lub być liczbą dodatnią. + Włącz nieskończone odtwarzanie + Gdy kolejka się skończy, odtwarzaj dalej polecane utwory. + Włącz Connect + Udostępniaj sterowanie odtwarzaniem przez funkcję Spotube Connect. + Port serwera proxy odtwarzania + Zezwól na zdalne sterowanie + Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu. + Nazwa urządzenia do zdalnego sterowania + Nazwa widoczna dla innych urządzeń podczas wykrywania. Obecnie: %1$s + Wybierz przyjazną nazwę, którą zobaczą inne urządzenia w Twojej sieci, gdy wykryją tę instancję Spotube. + np. Spotube w salonie + nazwa hosta urządzenia + Port używany przez serwer proxy odtwarzania. Obecnie: %1$d + Wybierz port od 1 do 65535. + 14769 + Wpisz poprawną liczbę całkowitą. + Port musi mieścić się w zakresie od 1 do 65535. + + Czarna lista + Zarządzaj utworami i wykonawcami na czarnej liście + + Minimalizuj do zasobnika + Po zminimalizowaniu okna Spotube działa dalej w zasobniku systemowym. + Status Discord (Rich Presence) + Pokazuj w Discordzie, czego teraz słuchasz (na komputerze). + + Automatycznie sprawdzaj aktualizacje + Wyszukuj nowe wersje Spotube w tle. + + Zarządzaj wtyczkami + Zarządzaj swoimi wtyczkami + Domyślna wtyczka: %1$s + Metadane + Audio + Teksty + Scrobbling + Nie wybrano wtyczki + Zmień + Wybierz + Wybrano + Wyczyść wybór + Brak dostępnych wtyczek + Wtyczka %1$s + Wyczyść + + Konfiguruj + Menedżer wtyczek + Wpisz adres URL + Adres URL musi zaczynać się od http:// lub https:// + Nie udało się pobrać wtyczki + Nie zainstalowano jeszcze żadnych wtyczek + Wklej adres URL pobierania lub wybierz plik powyżej. + + %1$d wtyczka zainstalowana + %1$d wtyczki zainstalowane + %1$d wtyczek zainstalowanych + %1$d wtyczki zainstalowanej + + Zainstaluj wtyczkę + https://example.com/plugin.smplug + Pobierz + Zainstaluj z pliku (.smplug) + Aktywna + v%1$s + Wbudowana + Usuń wtyczkę + Zaloguj się + Wyloguj się + Pobierz z adresu URL + Zainstaluj z pliku + Zainstaluj + + • %1$s + Wymagane uprawnienia + Ta wtyczka nie wymaga specjalnych uprawnień. + Zainstalowana a dostarczona + Zainstalowana + Dostarczona + API %1$s → %2$s + Trwała pamięć + Odczyt i zapis danych na dysku + Żądania sieciowe + Wysyłanie i odbieranie danych przez internet + WebView + Wyświetlanie treści internetowych w aplikacji + Grupowy Jam + Broker MQTT + Host brokera + %1$s:%2$d + Port brokera + Użyj TLS + Nazwa użytkownika (opcjonalnie) + Hasło (opcjonalnie) + Prefiks ID klienta + Służy do identyfikacji tej instancji Spotube + Szyfruj połączenie z brokerem + Sprawdź bieżące ustawienia brokera przed utworzeniem pokoju. + Testuj połączenie + Testowanie… + Połączono w %1$d ms + Niepowodzenie: %1$s + Broker zastępczy – skonfiguruj własny, aby hostować samodzielnie + Połączenie + Dane logowania + Tożsamość + Diagnostyka + + + Grupowy Jam + Utwórz + Dołącz + Utwórz pokój, a potem udostępnij jego kod znajomym. + Utwórz pokój + Dołącz do pokoju + Łączenie… + Kod pokoju + ABC123 + Broker: %1$s + Skonfiguruj brokera w Ustawieniach, aby utworzyć pokój lub do niego dołączyć. + Ustawienia brokera + Połączono + Ponowne łączenie… + Kopiuj kod + Skopiowano! + Udostępnij + Uczestnicy (%1$d) + Gospodarz + Słuchacz + Czekamy na słuchaczy… + Usuń + Zablokuj + Zarządzaj + Opuść pokój + Nie udało się połączyć + Zamknij + Nie udało się utworzyć sesji Jam + Nie udało się dołączyć do sesji Jam + Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr. + Dołącz do mojego Jamu w Spotube, kod: %1$s + + Przejdź do albumu + Wyłącznik czasowy + Szczegóły utworu + Odtwórz + Wstrzymaj + Nic nie jest odtwarzane + Dodaj do kolejki + + Ignoruj + Aktualizuj + Dostępna jest wersja Spotube %1$s + Sprawdź nowości w tej wersji na stronie pobierania Spotube. + Dostępna jest wersja %2$s wtyczki %1$s + Obecnie zainstalowana: %1$s + Sprawdź nowości w tej aktualizacji na stronie wtyczki. + + Pomiń + Dalej + Wstecz + Zaczynamy + Spotube + Muzyka bez żadnych warunków. + Otwartoźródłowy odtwarzacz dla całego świata muzyki.\nOdkrywaj go. Zrób go po swojemu. + Pomóż utrzymać ten projekt. + Spotube tworzą ludzie, a nie biznes oparty na subskrypcjach. Jeśli stanie się częścią Twojej codzienności, niewielkie wsparcie pomoże nam dbać o niego tak, jak na to zasługuje.\n\nDziękujemy, że dajesz miejsce niezależnemu oprogramowaniu. + Zacznij od wtyczki katalogu, aby wyszukiwać utwory i playlisty. Wybierz wtyczkę audio, aby je odtwarzać. Teksty i historia słuchania są opcjonalne. Znajdź wtyczkę poniżej, sprawdź jej uprawnienia, zainstaluj ją, a potem wybierz ją tutaj. + Odkrywaj wtyczki + Twoje źródła + Spróbuj ponownie + Więcej wtyczek + Nie znaleziono nowych wtyczek. Możesz też zainstalować wtyczkę z linku lub pliku .smplug powyżej. + Wyszukiwanie wtyczek jest teraz niedostępne. Spróbuj ponownie lub zainstaluj z linku albo pliku. + Witaj + Wsparcie + Wtyczki + Przewodnik + + Jak korzystać ze Spotube + Cztery szybkie kroki i już słuchasz. + Zaloguj się + Połącz konto Spotify lub wybierz własną wtyczkę katalogu. + Szukaj i odtwarzaj + Znajdź dowolny utwór, album lub wykonawcę i naciśnij Odtwórz. + Słuchaj offline + Pobieraj utwory, aby słuchać ich bez połączenia. + Śledź tekst + Otwórz zsynchronizowany tekst w odtwarzaczu podczas słuchania utworu. + + + Twoja muzyka. Twoje źródła. Twoje zasady. + Darmowy, otwartoźródłowy odtwarzacz muzyki tworzony przez społeczność, która po prostu kocha muzykę. + Open source + Bez reklam + Bez telemetrii + Oparty na wtyczkach + Zaczynamy + + + Wspierający dodają nam sił + Stworzony z pasją. Utrzymywany dzięki Tobie. + Spotube jest w 100% darmowy, wolny i otwartoźródłowy — bez reklam, subskrypcji i śledzenia. Każda funkcja istnieje dzięki osobom takim jak Ty, które zdecydowały się wspierać rozwój kodu. + Patreon + Miesięczny sponsor + Wspieraj stały rozwój i infrastrukturę + Buy Me a Coffee + Jednorazowa kawa + Dodaj energii nocnej sesji kodowania drobnym napiwkiem + Open Collective + Fundusz społeczności + Przejrzyste wsparcie finansowe zarządzane przez społeczność + Dziękujemy z całego serca, że wierzysz w otwartą muzykę. + Wsparcie jest całkowicie dobrowolne — Spotube i tak pozostanie darmowy. + Wesprzyj + Otwórz %1$s + + + Wesprzyj Spotube + Wesprzyj Spotube + Spotube jest darmowy, wolny i otwartoźródłowy — bez reklam, subskrypcji i śledzenia. Jeśli sprawia Ci radość, rozważ wsparcie projektu. + Te przypomnienia możesz wyłączyć w Ustawienia → Wsparcie. + Może później + Przypomnienia o wsparciu + Co 7 dni pokazuj delikatne przypomnienie o wsparciu projektu. + + + Modułowa architektura + Wybierz, skąd pochodzi Twoja muzyka. + Spotube rozdziela odkrywanie katalogu, strumieniowanie dźwięku i zsynchronizowane teksty. Skonfiguruj silniki poniżej lub zmień je w dowolnej chwili. + Silnik metadanych + Odpowiada za odkrywanie muzyki, wyszukiwanie, listy utworów i playlisty. + Silnik audio + Pobiera i dekoduje odtwarzane strumienie audio. + Silnik tekstów + Wyświetla zsynchronizowane teksty karaoke w czasie rzeczywistym. + Silnik scrobblingu + Zapisuje historię słuchania w Last.fm lub ListenBrainz. + Aktywny + Wbudowany + Gotowy + Opcjonalny + Wybierz silnik + Zainstalowane silniki + Ustaw jako domyślny + NewPipe YouTube i LRCLib Lyrics są wbudowane i od razu gotowe do działania. + Zarządzaj wtyczkami społeczności lub dodawaj nowe w dowolnej chwili w Ustawienia → Wtyczki. + + + Retro-nowoczesna architektura + Czyste Hi-Fi, zero pośredników + Oto jak Spotube organizuje zdecentralizowane odtwarzanie bezpośrednio na Twoim komputerze. + BEZPOŚREDNIO PEER-TO-PEER • 256 KBPS OPUS + Niebieska godzina + Spotube · demonstracja odtwarzacza + Trochę muzyki na chwile pomiędzy. + 1. Zdecentralizowane odkrywanie + Zapytania trafiają prosto do wybranej wtyczki katalogu. Spotube nigdy nie profiluje Twojego gustu. + 2. Bezpośrednie pozyskiwanie strumienia + Dźwięk płynie bezpośrednio z serwerów treści na Twój komputer. Bez pośrednich serwerów proxy i zdalnego zbierania danych. + 3. Lokalne DSP i odtwarzanie + Dekodowanie i buforowanie odbywa się lokalnie, z korektorem i opcjonalnym pobieraniem offline. + 4. Zsynchronizowane teksty i Jam + Teksty karaoke podążają za odtwarzaniem, a do tego zdalne sterowanie w sieci LAN i Grupowy Jam. + Żadnych serwerów pośredniczących. Odtwarzanie odbywa się na Twoim urządzeniu — prywatność w standardzie. + + + Wybierz urządzenie + %1$s: „%2$s” + Odtwórz + Dodaj do kolejki + Odtwórz jako następny + To urządzenie + Na tym urządzeniu + Urządzenie zdalne + Urządzenie zdalne (%1$s) + Na połączonym urządzeniu + + + Połączenie nieudane + Utracono połączenie + Wysyłanie nieudane + Wykrywanie nieudane: %1$s + Nie udało się połączyć: %1$s + + + Urządzenia + Odśwież + Połączono z %1$s:%2$d + Rozłącz + Łączenie... + Wyszukiwanie urządzeń w sieci... + Nie znaleziono urządzeń + Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”. + Nieznane urządzenie + Ustalanie adresu... + ID: %1$s... + + + Zdalne sterowanie + Rozłączono + Błąd połączenia: %1$s + Okładka albumu + Nieznany utwór + Nieznany wykonawca + Przewiń + Odtwarzanie losowe + Poprzedni + Następny + Tryb powtarzania + Głośność + Usuń z kolejki + Więcej opcji + + + Gdzie odtworzyć? + Masz aktywną sesję Jam. Wybierz, gdzie odtworzyć tę kolekcję. + Odtwórz tutaj + Zaproponuj w Jamie + + + Nie skonfigurowano brokera Jam + Host brokera jest pusty + Połączenie nieudane + Utracono połączenie + + + Broker + anonim + + + Dodano do kolejki Jamu + + Dodano %1$d utwór do kolejki Jamu + Dodano %1$d utwory do kolejki Jamu + Dodano %1$d utworów do kolejki Jamu + Dodano %1$d utworu do kolejki Jamu + + + + Prośba o zdalne sterowanie + „%1$s” chce sterować odtwarzaniem na tym urządzeniu. + Odmów + Zezwól + Zawsze zezwalaj + + + Wyniki wyszukiwania + Wszystko + Utwory + Użytkownicy + Brak dostępnych typów wyszukiwania + Zacznij pisać, aby wyszukać + Zacznij pisać, aby wyszukać utwory + Brak wyników + Pokaż wszystko + Wyszukiwanie + Wczytywanie utworów... + Wczytywanie albumów... + Wczytywanie wykonawców... + Wczytywanie playlist... + Wczytywanie użytkowników... + Nie znaleziono albumów + Nie znaleziono wykonawców + Nie znaleziono playlist + Nie znaleziono użytkowników + Przekopujemy skrzynki z płytami... + Przeszukujemy kosmos... + Rozgrzewamy głośniki... + Odkurzamy winyle... + Radzimy się bogów muzyki... + Stroimy anteny... + Przeglądamy płyty... + Nie udało się wczytać możliwości wyszukiwania + Wyszukiwanie nie powiodło się + Nie udało się wyszukać utworów + Nie udało się wyszukać albumów + Nie udało się wyszukać wykonawców + Nie udało się wyszukać playlist + Nie udało się wyszukać użytkowników + + + Przeglądaj + + + Zsynchronizowany + Zwykły + Brak zsynchronizowanego tekstu + Brak zwykłego tekstu + + + %1$s w %2$s + %1$d kbps + %1$s kHz • %2$d kan. + Nie wybrano wtyczki metadanych + + + Wstecz + Ponów + Zamknij okno + Wczytywanie... + Nieznane + Nieznany błąd + + + Użytkownik + Playlista + + + Bardzo nam przykro + Coś teraz nie działa. Zależy nam na Twoich wrażeniach i robimy wszystko, aby to naprawić. Spróbuj ponownie lub sprawdź szczegóły błędu poniżej. + Szczegóły błędu + Kopiuj do schowka + Pokaż pełny dziennik + Pełny dziennik błędu + + + Losowo + Odtwarzaj losowo + Obserwuj + Przestań obserwować + Edytuj playlistę + Autor: %1$s + + + Opcje utworu + Dodaj do Jamu + Włącz radio + Odtwórz jako następny + Przenieś na następny + Usuń z kolejki + Usuń z ulubionych + Dodaj do ulubionych + Dodaj do playlisty + Pobierz + Udostępnij + + + Oryginalna + Tytuł + Wykonawca + Album + Czas trwania + Sortuj + Nie znaleziono utworów + Filtruj + Działania zbiorcze + Pobierz wszystkie + Pobierz (%1$d) + Dodaj wszystkie do kolejki + Dodaj do kolejki (%1$d) + Odtwórz wszystkie jako następne + Odtwórz jako następne (%1$d) + Dodaj wszystkie do playlisty + Dodaj do playlisty (%1$d) + Dodaj wszystkie do Jamu + Dodaj do Jamu (%1$d) + +%1$d więcej + + + Brak adresu URL do wczytania + Wstecz w historii przeglądarki + Dalej w historii przeglądarki + + + Filtruj czarną listę... + Czarna lista jest pusta + Brak elementów czarnej listy pasujących do „%1$s” + Utwory + Utwór + Dodaj do czarnej listy + Usuń z czarnej listy + + + Brak metadanych utworu + Nie udało się ustalić adresu URL strumienia + Nie udało się ustalić adresu URL strumienia. Spróbuj najpierw odtworzyć utwór. + + + Zapisane utwory + Ty + + + Obserwujesz + Popularne utwory + Odtwórz popularne utwory + Dodaj popularne utwory do kolejki + Podobni wykonawcy + Polecane playlisty + Nie udało się wczytać przeglądu wykonawcy + Liczba obserwujących niedostępna + %1$s tys. obserwujących + %1$s mln obserwujących + %1$s mld obserwujących + + %1$d obserwujący + %1$d obserwujących + %1$d obserwujących + %1$d obserwującego + + + + Polub + Cofnij polubienie + Tytuł utworu zastępczego + Wykonawcy zastępczy + Nieznany album + Przewiń + Głośność + Włącz odtwarzanie losowe + Wyłącz odtwarzanie losowe + Poprzedni + Następny + Odtwórz lub wstrzymaj + Powtarzanie wyłączone + Powtarzaj jeden utwór + Powtarzaj wszystko + Wycisz + Wyłącz wyciszenie + Więcej opcji + Opcje odtwarzacza + Zwiń odtwarzacz + Rozwiń tekst + Teraz odtwarzane + Tekst + Alternatywne źródło + Alternatywne źródło + Pobierz + Pobrano + Pobieranie... + Już pobrano + Ponawianie pobierania... + Pobieranie: %1$s + Odtwarzanie zostanie wstrzymane za %1$s. + Wstrzymaj odtwarzanie po wybranej liczbie minut. + Minuty + Wybierz od 1 do 600 minut + Anuluj wyłącznik + Start + Adres URL odtwarzania został ustalony przy innych ustawieniach strumieniowania. Wyróżniony strumień odpowiada Twoim obecnym preferencjom; aktywny adres URL odświeży się przy następnym żądaniu strumienia. + Dopasowane źródło audio + Wczytywanie dopasowanego źródła… + Dopasowane zdalne źródło nie jest potrzebne podczas odtwarzania lokalnego pliku z pamięci podręcznej. + Brak dostępnego dopasowanego źródła. + Brak dopasowanego źródła audio w pamięci podręcznej dla tego utworu. + Szczegóły strumienia nie są jeszcze zapisane. Najpierw odtwórz utwór, aby je wczytać. + Nie udało się wczytać szczegółów utworu. + Dostępne strumienie + Ten utwór jest odtwarzany z lokalnej pamięci podręcznej. Warianty strumienia nie są używane podczas tego odtwarzania. + Wczytywanie strumieni… + Brak strumieni dla tego źródła. + Obecnie wybrany strumień + Zgodny z preferowanym formatem i jakością strumieniowania + Aktywny adres URL odtwarzania + Odtwarzane z lokalnej pamięci podręcznej + Utwór z metadanych + Tytuł + ID + Album + Pewność + Zewnętrzny URI + ID albumu + Typ albumu + Data wydania albumu + Liczba utworów w albumie + Gatunki albumu + Czas trwania + Numer utworu + Numer płyty + Treści dla dorosłych + Popularność + ISRC + Adresy URL miniatur + + + Przełącz pasek boczny + + + Alternatywne źródła + Nic nie jest obecnie odtwarzane + Wyszukiwanie alternatywnych źródeł... + Nie znaleziono alternatywnych źródeł + Pewność: %1$d% + Aktywne źródło + + + Wyrzuć + Usuń tę osobę z sesji + Wyrzuć i uniemożliw ponowne dołączenie + Usuń propozycje + Usuń wszystkie utwory dodane przez tę osobę do kolejki + Zmień kolejność + Usuń z kolejki + %1$s • Dodane przez: %2$s + + + Nieznany + Wczytywanie playlisty... + Dodaj utwory + Dodaj utwory do playlisty + Szukaj utworów... + Szukaj + Nie znaleziono utworów + Wyszukaj utwory do dodania + Gotowe + Dodaj + Dodano + + + Wczytywanie albumu... + + + Dodaj do swojej playlisty + Szukaj + Szukaj w swoich playlistach... + Wczytywanie Twoich playlist... + Nie masz jeszcze własnych playlist.\nUtwórz playlistę, aby dodać utwory. + Utwórz playlistę + Edytuj playlistę + Utwórz + Nazwa + Nazwa playlisty + Opis + Opis (opcjonalnie) + Publiczna + Każdy może zobaczyć tę playlistę + Wspólna + Inni mogą dodawać utwory do tej playlisty + Okładka + Okładka playlisty + Brak obrazu + Zmień obraz + Wybierz obraz + + + Zainstaluj + Zastąp + Nie znaleziono pliku plugin.json w archiwum zip + Nie znaleziono pliku manifest.zipline.json w archiwum zip + Nieprawidłowy format pliku plugin.json: %1$s + Nie udało się odczytać wtyczki: %1$s + Nie udało się zainstalować wtyczki: %1$s + Dostępna jest nowsza wersja %1$s. Potwierdź aktualizację przed instalacją. + %1$s jest już zainstalowana. Potwierdź zastąpienie przed instalacją. + Wbudowane wtyczki są już dołączone i nie można ich dodać ponownie. + Nie można usunąć wbudowanych wtyczek. + %1$s deklaruje nieprawidłową wartość apiVersion (%2$s). Oczekiwano wersji semantycznej zgodnej z %3$s. + Wersja API wtyczek Spotube %1$s jest nieprawidłowa. + %1$s jest przeznaczona dla API wtyczek %2$s, a Spotube obsługuje niezgodne wstecz API wtyczek %3$s. Zainstaluj wtyczkę zbudowaną dla tej samej głównej wersji API. + + + Niezgodne API wtyczki + Zainstalować wtyczkę? + %1$s zostanie dodana do zainstalowanych wtyczek. + Zaktualizować wtyczkę? + Zainstalowana jest starsza wersja (%1$s). Zaktualizować do %2$s? + Zastąpić zainstalowaną wtyczkę? + %1$s używa nieprawidłowej wersji semantycznej (%2$s) i nie można jej porównać jako aktualizacji. Mimo to zastąpić zainstalowaną wtyczkę? + Zainstalowanej wersji (%1$s) nie można porównać za pomocą wersjonowania semantycznego. Zastąpić ją wersją %2$s? + Wersja %1$s jest już zainstalowana. Zastąpić istniejącą wtyczkę dostarczoną kopią? + Zainstalowana wersja %1$s jest nowsza niż dostarczona wersja %2$s. Mimo to zastąpić? + Zastąpić zainstalowaną wtyczkę dostarczoną kopią? + + + %1$s: + Wersja + Wersja API + Autor + Licencja + Uprawnienia + Funkcje + Repozytorium + Kontakt + Zgłoś błędy + Wesprzyj %1$s + + + Oficjalna + Zweryfikowana + Gwiazdki w GitHub + Adres URL repozytorium GitHub + + + Nie znaleziono wydań + Wybierz wydanie + Wybierz wydanie + Wersja przedpremierowa + Informacje o wydaniu + + + Główna + Szukaj + Na urządzeniu + Połącz + Urządzenia + + + Ukryj okno + Pokaż okno + Następny utwór + Poprzedni utwór + Losowo + Powtarzanie + Powtarzanie: WYŁ. + Powtarzanie: JEDEN + Powtarzanie: WSZYSTKO + Głośność: %1$d% + Głośność + + Głośność - + Wycisz + Wyłącz wyciszenie + Polub utwór + Cofnij polubienie utworu + Zakończ + + + Minimalizuj + Maksymalizuj + Przywróć + Zamknij + + + Usługa odtwarzania + Odtwarzanie + Sterowanie odtwarzaniem multimediów + + + Przeglądaj + Teraz odtwarzane + Polecane + Zapisane utwory + Popularne utwory + diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml new file mode 100644 index 00000000..3694b5c2 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -0,0 +1,944 @@ + + + + Fila + Limpar fila + Filtrar fila... + A fila está vazia + + Buscar músicas, artistas, álbuns... + Limpar todo o histórico + Buscar... + + + Sua Biblioteca + Biblioteca + Playlists + Álbuns + Artistas + Arquivos locais + Downloads + Buscar playlists salvas… + Buscar álbuns salvos… + Buscar artistas salvos… + Buscar pastas ou faixas… + Filtrar biblioteca + Fechar filtro + Criar playlist + Atualizar arquivos locais + Mudar para visualização em grade + Mudar para visualização em lista + Nenhuma playlist salva + Nenhuma playlist corresponde a “%1$s” + Nenhum álbum salvo + Nenhum álbum corresponde a “%1$s” + Nenhum artista salvo + Nenhum artista corresponde a “%1$s” + + + Pastas + Pasta + %1$s • %2$s + + %1$d faixa + %1$d de faixas + %1$d faixas + + + %1$d pasta + %1$d de pastas + %1$d pastas + + Voltar para pastas + Tocar pasta + Acesso ao áudio necessário + Permita o acesso ao áudio para navegar pelas músicas do seu dispositivo. + Permitir acesso + Nenhuma pasta local encontrada + Nenhuma faixa nesta pasta + Nenhuma pasta ou faixa correspondente + Falha ao atualizar a mídia local + + + %1$d em andamento • %2$d concluídos + Limpar concluídos + Nenhum download ainda + Baixando %1$d% + Na fila + Concluído + Cancelado + Falhou + Falhou: %1$s + Cancelar download + Tentar baixar de novo + Remover download + + + Artista + Bloqueado + Músicas Curtidas + Suas músicas salvas + Artista desconhecido + Título da faixa + Nome do artista + + Selecionado + + Configurações + + Plugins + Idioma e região + Aparência + Reprodução + Downloads + Cache + Desktop + Atualizações + Apoio + Sobre + + Fechar + Salvar + Cancelar + + Idioma + Idioma da interface do app. Atual: %1$s + Padrão do sistema + País + País usado para conteúdo e disponibilidade. Atual: %1$s + %1$s (%2$s) + + Tema + Escolha como o Spotube acompanha a aparência do sistema. Atual: %1$s + Claro + Escuro + Sistema + + Superfície + Material de fundo do app. Atual: %1$s + Aquoso + Gelo + Cromado + Acrílico + Vidro + + Cor de destaque + Paleta atual: %1$s + Cor de destaque + Escolha uma paleta de destaque e veja como fica nos temas claro e escuro. + Claro + Escuro + Prévia + Botão + Duende Verde + Violeta Elétrico + Ciano Oceânico + Laranja Pôr do Sol + Jardim de Rosas + Azul Meia-Noite + Ardósia Metálica + + Visual + Material da superfície e destaque. Atual: %1$s · %2$s + Visual + Escolha um material de superfície e depois um destaque que combine com ele. Cada opção é uma combinação selecionada. + Superfície + Destaque + Secundário + + Paleta + Paleta de destaque Millennium. Atual: %1$s + Paleta + Escolha uma paleta de destaque Millennium e veja como fica no claro e no escuro. + Paleta + Paleta Millennium e destaque. Atual: %1$s + Azul + Laranja + Lima + + Pasta de download personalizada + Pasta atual: %1$s + Pasta atual: pasta de músicas padrão + Deixe em branco para usar a pasta de músicas padrão. + C:\Music\Spotube + Pastas de mídia local + Pastas personalizadas a serem verificadas no desktop. Adicionadas: %1$d + Nenhuma pasta personalizada adicionada ainda + Pastas personalizadas para sua biblioteca de músicas local. + Gerenciar + Adicionar pasta + Remover pasta + Nenhuma pasta personalizada adicionada. + Remover %1$s da lista de verificação + As pastas de download e de cache de músicas são incluídas automaticamente. + Sua pasta de download é incluída automaticamente. + A pasta de cache de músicas é incluída automaticamente. + Salvando… + Não foi possível salvar as pastas. Suas alterações continuam aqui; tente de novo. + + %1$d pasta + %1$d de pastas + %1$d pastas + + Formato de download das músicas + Codec e contêiner preferidos para downloads. Atual: %1$s + Qualidade de download das músicas + + Formato de streaming das músicas + Codec e contêiner preferidos para o streaming de música. Atual: %1$s + Qualidade de streaming das músicas + Atual: %1$s + Ativar cache de músicas + Armazena o áudio transmitido no dispositivo para acelerar as próximas reproduções. + Pasta de cache + Atual: %1$s + Atual: cache padrão do app + Limite de tamanho do cache + Sem limite + Limite atual: %1$d MB + Defina o tamanho máximo do cache em MB. Digite 0 ou deixe em branco para não ter limite. + 500 + O tamanho deve ser 0 ou um número positivo. + Ativar reprodução contínua + Continua tocando faixas recomendadas quando sua fila acabar. + Ativar Connect + Disponibiliza controles de reprodução remota pelo recurso Spotube Connect. + Porta do servidor proxy de reprodução + Permitir controle remoto + Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem. + Nome do dispositivo para controle remoto + O nome que outros dispositivos veem ao encontrar este. Atual: %1$s + Escolha um nome amigável para exibir aos outros dispositivos da sua rede quando encontrarem esta instância do Spotube. + ex.: Spotube da Sala + nome do host do dispositivo + Porta usada pelo servidor proxy de reprodução. Atual: %1$d + Escolha uma porta entre 1 e 65535. + 14769 + Digite um número inteiro válido. + A porta deve estar entre 1 e 65535. + + Lista de bloqueio + Gerencie faixas e artistas bloqueados + + Minimizar para a bandeja + Mantém o Spotube em execução na bandeja do sistema quando a janela for minimizada. + Rich Presence do Discord + Mostra o que você está ouvindo no Discord no desktop. + + Verificar atualizações automaticamente + Procura novas versões do Spotube em segundo plano. + + Gerenciar plugins + Gerencie seus plugins + Plugin de %1$s padrão + Metadados + Áudio + Letras + Scrobble + Nenhum plugin selecionado + Alterar + Selecionar + Selecionado + Limpar seleção + Nenhum plugin disponível + Plugin %1$s + Limpar + + Configurar + Gerenciador de plugins + Digite uma URL + A URL deve começar com http:// ou https:// + Falha ao baixar o plugin + Nenhum plugin instalado ainda + Cole uma URL de download ou escolha um arquivo acima. + + %1$d plugin instalado + %1$d de plugins instalados + %1$d plugins instalados + + Instalar um plugin + https://example.com/plugin.smplug + Baixar + Instalar de arquivo (.smplug) + Ativo + v%1$s + Integrado + Remover plugin + Entrar + Sair + Baixar de URL + Instalar de arquivo + Instalar + + • %1$s + Permissões solicitadas + Este plugin não precisa de permissões especiais. + Instalado vs. fornecido + Instalado + Fornecido + API %1$s → %2$s + Armazenamento persistente + Ler e gravar dados no disco + Solicitações de rede + Enviar e receber dados pela internet + WebView + Exibir conteúdo da web dentro do app + Jam em grupo + Broker MQTT + Host do broker + %1$s:%2$d + Porta do broker + Usar TLS + Nome de usuário (opcional) + Senha (opcional) + Prefixo do ID do cliente + Usado para identificar esta instância do Spotube + Criptografa a conexão com o broker + Verifique as configurações atuais do broker antes de criar uma sala. + Testar conexão + Testando… + Conectado em %1$d ms + Falhou: %1$s + Broker provisório — configure o seu próprio para hospedar por conta própria + Conexão + Credenciais + Identidade + Diagnóstico + + + Jam em grupo + Criar + Entrar + Crie uma sala e compartilhe o código com seus amigos. + Criar sala + Entrar na sala + Conectando… + Código da sala + ABC123 + Broker: %1$s + Configure um broker nas Configurações para criar ou entrar em uma sala. + Configurações do broker + Conectado + Reconectando… + Copiar código + Copiado! + Compartilhar + Participantes (%1$d) + Anfitrião + Ouvinte + Aguardando ouvintes… + Remover + Banir + Gerenciar + Sair da sala + Não foi possível conectar + Dispensar + Falha ao criar a Jam + Falha ao entrar na Jam + Digite um código de sala com seis caracteres, usando letras e números. + Entre na minha Jam do Spotube com o código: %1$s + + Ir para o álbum + Timer + Detalhes da faixa + Tocar + Pausar + Nada tocando + Adicionar à fila + + Ignorar + Atualizar + Spotube %1$s está disponível + Veja as novidades desta versão na página de downloads do Spotube. + %1$s %2$s está disponível + Versão instalada: %1$s + Veja as novidades desta atualização na página do plugin. + + Pular + Avançar + Voltar + Começar + Spotube + Música, sem amarras. + Um player de código aberto para um mundo de música.\nPara você explorar. Para você deixar do seu jeito. + Mantenha este projeto vivo. + O Spotube é feito por pessoas, não por uma empresa de assinaturas. Se ele fizer parte do seu dia a dia, um pequeno apoio nos ajuda a dar a ele o cuidado que merece.\n\nObrigado por abrir espaço para software independente. + Comece com um plugin de catálogo para encontrar músicas e playlists. Escolha um plugin de áudio para tocá-las. Letras e histórico de reprodução são opcionais. Descubra um plugin abaixo, revise as permissões, instale-o e depois selecione-o aqui. + Descobrir plugins + Suas fontes + Tentar de novo + Mais plugins + Nenhum plugin novo encontrado. Você também pode instalar um a partir de um link ou de um arquivo .smplug acima. + A descoberta de plugins está indisponível no momento. Tente de novo ou instale a partir de um link ou arquivo. + Boas-vindas + Apoio + Plugins + Guia + + Como usar o Spotube + Quatro passos rápidos e você já está ouvindo. + Entrar + Conecte sua conta do Spotify ou escolha seu próprio plugin de catálogo. + Busque e toque + Encontre qualquer música, álbum ou artista e aperte o play. + Ouça offline + Baixe faixas para continuar ouvindo sem conexão. + Acompanhe a letra + Abra a letra sincronizada no player enquanto a faixa toca. + + + Sua música. Suas fontes. Suas regras. + Um player de música gratuito e de código aberto, feito por uma comunidade que simplesmente ama música. + Código aberto + Sem anúncios + Sem telemetria + Movido a plugins + Vamos lá + + + Apoiadores nos mantêm de pé + Feito com paixão. Mantido vivo por você. + O Spotube é 100% gratuito, livre e de código aberto — sem anúncios, sem assinaturas, sem rastreamento. Cada recurso existe porque pessoas como você decidiram ajudar a manter o código fluindo. + Patreon + Apoio mensal + Apoie o desenvolvimento contínuo e a infraestrutura + Buy Me a Coffee + Um café avulso + Dê energia a uma sessão de programação noturna com uma gorjeta rápida + Open Collective + Fundo da comunidade + Apoio financeiro transparente e gerido pela comunidade + Do fundo do coração, obrigado por acreditar na música aberta. + Apoiar é totalmente opcional — o Spotube continua gratuito de qualquer forma. + Apoiar + Abrir %1$s + + + Apoiar o Spotube + Apoie o Spotube + O Spotube é gratuito, livre e de código aberto — sem anúncios, sem assinaturas, sem rastreamento. Se ele te traz alegria, considere apoiar o projeto. + Você pode desativar esses lembretes em Configurações → Apoio. + Agora não + Lembretes de apoio + Mostra um lembrete gentil para apoiar o projeto a cada 7 dias. + + + Arquitetura modular + Escolha de onde vem a sua música. + O Spotube separa a descoberta do catálogo, o streaming de áudio e as letras sincronizadas. Configure seus motores abaixo ou troque-os quando quiser. + Motor de metadados + Alimenta a descoberta de músicas, a busca, as listas de faixas e as playlists. + Motor de áudio + Obtém e decodifica os streams de áudio que tocam. + Motor de letras + Transmite letras de karaokê sincronizadas em tempo real. + Motor de scrobble + Registra o histórico de reprodução no Last.fm ou no ListenBrainz. + Ativo + Integrado + Pronto + Opcional + Selecione um motor + Motores instalados + Definir como padrão + NewPipe YouTube e LRCLib Lyrics já vêm integrados e prontos para usar. + Gerencie ou adicione novos plugins da comunidade quando quiser em Configurações → Plugins. + + + Arquitetura retrô-moderna + Hi-Fi puro, zero intermediários + Veja como o Spotube orquestra a reprodução descentralizada diretamente na sua máquina. + PEER-TO-PEER DIRETO • OPUS 256 KBPS + Hora azul + Spotube · demonstração do player + Um pouco de música para os momentos entre um e outro. + 1. Descoberta descentralizada + As buscas vão direto para o plugin de catálogo que você escolheu. O Spotube nunca traça o perfil do seu gosto. + 2. Resolução direta do stream + O áudio vem direto dos servidores de conteúdo para a sua máquina. Sem saltos por proxy nem coleta remota de dados. + 3. DSP e reprodução locais + Decodificado e armazenado em cache localmente, com controles de equalizador e download offline opcional. + 4. Letras sincronizadas e Jam + As letras de karaokê acompanham o tempo da reprodução, com controle remoto via LAN e Jam em grupo. + Sem servidores intermediários. A reprodução acontece no seu dispositivo — privado por natureza. + + + Escolha um dispositivo + %1$s: “%2$s” + Tocar + Adicionar à fila + Tocar em seguida + Este dispositivo + Neste dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + No dispositivo conectado + + + Falha na conexão + Conexão perdida + Falha no envio + Falha na descoberta: %1$s + Falha ao conectar: %1$s + + + Dispositivos + Atualizar + Conectado a %1$s:%2$d + Desconectar + Conectando... + Procurando dispositivos na rede... + Nenhum dispositivo encontrado + Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações. + Dispositivo desconhecido + Resolvendo... + ID: %1$s... + + + Controle remoto + Desconectado + Erro de conexão: %1$s + Capa do álbum + Faixa desconhecida + Artista desconhecido + Avançar/retroceder + Ordem aleatória + Anterior + Próxima + Modo de repetição + Volume + Remover da fila + Mais opções + + + Tocar onde? + Você tem uma Jam ativa. Escolha onde tocar esta coleção. + Tocar aqui + Sugerir na Jam + + + Nenhum broker de Jam configurado + O host do broker está vazio + Falha na conexão + Conexão perdida + + + Broker + anônimo + + + Adicionada à fila da Jam + + %1$d faixa adicionada à fila da Jam + %1$d de faixas adicionadas à fila da Jam + %1$d faixas adicionadas à fila da Jam + + + + Solicitação de controle remoto + \"%1$s\" quer controlar a reprodução neste dispositivo. + Negar + Permitir + Sempre permitir + + + Resultados da busca + Tudo + Faixas + Usuários + Nenhum tipo de busca disponível + Comece a digitar para buscar + Comece a digitar para buscar faixas + Nenhum resultado encontrado + Ver tudo + Buscando + Carregando faixas... + Carregando álbuns... + Carregando artistas... + Carregando playlists... + Carregando usuários... + Nenhum álbum encontrado + Nenhum artista encontrado + Nenhuma playlist encontrada + Nenhum usuário encontrado + Garimpando nos caixotes de discos... + Vasculhando o cosmos... + Aquecendo as caixas de som... + Tirando o pó do vinil... + Consultando os deuses da música... + Sintonizando as antenas... + Folheando os discos... + Falha ao carregar os recursos de busca + Falha na busca + Falha ao buscar faixas + Falha ao buscar álbuns + Falha ao buscar artistas + Falha ao buscar playlists + Falha ao buscar usuários + + + Explorar + + + Sincronizada + Simples + Nenhuma letra sincronizada disponível + Nenhuma letra simples disponível + + + %1$s em %2$s + %1$d kbps + %1$s kHz • %2$d canais + Nenhum plugin de metadados selecionado + + + Voltar + Tentar de novo + Fechar caixa de diálogo + Carregando... + Desconhecido + Erro desconhecido + + + Usuário + Playlist + + + Pedimos sinceras desculpas + Algo não está funcionando agora. Sua experiência importa para nós, e estamos fazendo o possível para resolver isso. Tente de novo ou confira os detalhes do erro abaixo. + Detalhes do erro + Copiar para a área de transferência + Ver log completo + Log de erro completo + + + Aleatório + Tocar em ordem aleatória + Seguir + Deixar de seguir + Editar playlist + De %1$s + + + Opções da faixa + Adicionar à Jam + Iniciar rádio + Tocar em seguida + Mover para a próxima + Remover da fila + Remover dos favoritos + Salvar nos favoritos + Adicionar à playlist + Baixar + Compartilhar + + + Original + Título + Artista + Álbum + Duração + Ordenar + Nenhuma faixa encontrada + Filtrar + Ações em massa + Baixar tudo + Baixar %1$d + Adicionar tudo à fila + Adicionar %1$d à fila + Tocar tudo em seguida + Tocar %1$d em seguida + Adicionar tudo à playlist + Adicionar %1$d à playlist + Adicionar tudo à Jam + Adicionar %1$d à Jam + +%1$d outros + + + Nenhuma URL para carregar + Voltar no histórico do navegador + Avançar no histórico do navegador + + + Filtrar lista de bloqueio... + Nenhum item bloqueado + Nenhum item bloqueado corresponde a \'%1$s\' + Faixas + Faixa + Adicionar à lista de bloqueio + Remover da lista de bloqueio + + + Sem metadados da faixa + Não foi possível obter a URL do stream + Não foi possível obter a URL do stream. Tente tocar a faixa primeiro. + + + Faixas salvas + Você + + + Seguindo + Mais tocadas + Tocar as mais tocadas + Adicionar as mais tocadas à fila + Artistas relacionados + Playlists em destaque + Falha ao carregar a visão geral do artista + Seguidores indisponíveis + %1$s mil seguidores + %1$s mi de seguidores + %1$s bi de seguidores + + %1$d seguidor + %1$d de seguidores + %1$d seguidores + + + + Curtir + Descurtir + Título de faixa genérico + Artistas genéricos + Álbum desconhecido + Avançar/retroceder + Volume + Ativar ordem aleatória + Desativar ordem aleatória + Anterior + Próxima + Tocar ou pausar + Repetição desativada + Repetir uma + Repetir todas + Silenciar + Ativar som + Mais opções + Opções do player + Recolher player + Expandir letra + Tocando agora + Letra + Fonte alternativa + Fonte alternativa + Baixar + Baixada + Baixando... + Já baixada + Tentando baixar de novo... + Baixando %1$s + A reprodução será pausada em %1$s. + Pausa a reprodução após o número de minutos selecionado. + Minutos + Escolha entre 1 e 600 minutos + Cancelar timer + Iniciar + A URL de reprodução foi obtida com outras configurações de streaming. O stream destacado corresponde à sua preferência atual; a URL ativa será atualizada na próxima solicitação de stream. + Fonte de áudio correspondente + Carregando fonte correspondente… + Uma fonte remota correspondente não é necessária enquanto o arquivo em cache local estiver tocando. + Nenhuma fonte correspondente disponível. + Nenhuma fonte de áudio correspondente está em cache para esta faixa. + Os detalhes do stream ainda não estão em cache. Toque a faixa primeiro para carregá-los. + Falha ao carregar os detalhes da faixa. + Streams disponíveis + Esta faixa está sendo reproduzida do cache local. As variantes de stream não são usadas nesta reprodução. + Carregando streams… + Nenhum stream disponível para esta fonte. + Stream selecionado no momento + Corresponde à sua preferência de formato e qualidade de streaming + URL de reprodução ativa + Reproduzida do cache local + Faixa nos metadados + Título + ID + Álbum + Confiança + URI externo + ID do álbum + Tipo de álbum + Data de lançamento do álbum + Número de faixas do álbum + Gêneros do álbum + Duração + Número da faixa + Número do disco + Explícito + Popularidade + ISRC + URLs das miniaturas + + + Alternar barra lateral + + + Fontes alternativas + Nenhuma faixa tocando no momento + Procurando fontes alternativas... + Nenhuma fonte alternativa encontrada + Confiança: %1$d% + Fonte ativa + + + Expulsar + Remove a pessoa da sessão + Expulsa e impede que a pessoa volte a entrar + Remover sugestões + Remove todas as faixas que a pessoa adicionou à fila + Reordenar + Remover da fila + %1$s • Adicionada por %2$s + + + Desconhecido + Carregando playlist... + Adicionar faixas + Adicionar faixas à playlist + Buscar faixas... + Buscar + Nenhuma faixa encontrada + Busque faixas para adicionar + Concluído + Adicionar + Adicionada + + + Carregando álbum... + + + Adicionar à sua playlist + Buscar + Buscar nas suas playlists... + Carregando suas playlists... + Você ainda não tem nenhuma playlist.\nCrie uma para adicionar faixas. + Criar playlist + Editar playlist + Criar + Nome + Nome da playlist + Descrição + Descrição (opcional) + Pública + Qualquer pessoa pode ver esta playlist + Colaborativa + Outras pessoas podem adicionar faixas a esta playlist + Imagem de capa + Capa da playlist + Sem imagem + Alterar imagem + Escolher imagem + + + Instalar + Substituir + plugin.json não encontrado no arquivo zip + manifest.zipline.json não encontrado no arquivo zip + Formato de plugin.json inválido: %1$s + Falha ao ler o plugin: %1$s + Falha ao instalar o plugin: %1$s + Há uma versão mais recente de %1$s disponível. Confirme a atualização antes de instalar. + %1$s já está instalado. Confirme a substituição antes de instalar. + Os plugins integrados já estão incluídos e não podem ser adicionados de novo. + Os plugins integrados não podem ser removidos. + %1$s declara uma apiVersion inválida (%2$s). Era esperado um versionamento semântico compatível com %3$s. + A versão %1$s da API de plugins do Spotube é inválida. + %1$s foi feito para a API de plugins %2$s, mas o Spotube suporta a versão principal %3$s da API. Instale um plugin feito para a mesma versão principal da API. + + + API do plugin incompatível + Instalar plugin? + %1$s será adicionado aos seus plugins instalados. + Atualizar plugin? + Uma versão mais antiga está instalada (%1$s). Atualizar para %2$s? + Substituir plugin instalado? + %1$s usa uma versão semântica inválida (%2$s) e não pode ser comparado como uma atualização. Substituir o plugin instalado mesmo assim? + A versão instalada (%1$s) não pode ser comparada usando versionamento semântico. Substituí-la por %2$s? + A versão %1$s já está instalada. Substituir o plugin existente pela cópia fornecida? + A versão instalada %1$s é mais recente que a versão fornecida %2$s. Substituir mesmo assim? + Substituir o plugin instalado pela cópia fornecida? + + + %1$s: + Versão + Versão da API + Autor + Licença + Permissões + Funções + Repositório + Contato + Relatar bugs + Apoiar %1$s + + + Oficial + Verificado + Estrelas no GitHub + URL do repositório no GitHub + + + Nenhuma versão encontrada + Selecionar versão + Selecione uma versão + Pré-lançamento + Notas da versão + + + Início + Buscar + No dispositivo + Conectar + Dispositivos + + + Ocultar janela + Mostrar janela + Próxima faixa + Faixa anterior + Ordem aleatória + Repetir + Repetir: DESATIVADO + Repetir: UMA + Repetir: TODAS + Volume: %1$d% + Volume + + Volume - + Silenciar + Ativar som + Curtir faixa + Descurtir faixa + Sair + + + Minimizar + Maximizar + Restaurar + Fechar + + + Serviço de reprodução + Reprodução + Controles de reprodução de mídia + + + Explorar + Tocando agora + Em destaque + Faixas salvas + Mais tocadas + diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml new file mode 100644 index 00000000..860089e6 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -0,0 +1,950 @@ + + + + Очередь + Очистить очередь + Фильтр очереди... + Очередь пуста + + Треки, исполнители, альбомы... + Очистить историю + Поиск... + + + Моя медиатека + Медиатека + Плейлисты + Альбомы + Исполнители + Локальные треки + Загрузки + Поиск по сохранённым плейлистам… + Поиск по сохранённым альбомам… + Поиск по сохранённым исполнителям… + Поиск папок или треков… + Фильтр медиатеки + Закрыть фильтр + Создать плейлист + Обновить локальные треки + Показать сеткой + Показать списком + Нет сохранённых плейлистов + Нет плейлистов по запросу «%1$s» + Нет сохранённых альбомов + Нет альбомов по запросу «%1$s» + Нет сохранённых исполнителей + Нет исполнителей по запросу «%1$s» + + + Папки + Папка + %1$s • %2$s + + %1$d трек + %1$d трека + %1$d треков + %1$d трека + + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Назад к папкам + Слушать папку + Нужен доступ к аудио + Разрешите доступ к аудио, чтобы просматривать музыку на устройстве. + Разрешить доступ + Локальные папки не найдены + В этой папке нет треков + Подходящих папок или треков нет + Не удалось обновить локальные файлы + + + Активных: %1$d • Завершённых: %2$d + Очистить завершённые + Загрузок пока нет + Загрузка %1$d% + В очереди + Завершено + Отменено + Ошибка + Ошибка: %1$s + Отменить загрузку + Повторить загрузку + Удалить загрузку + + + Исполнитель + В чёрном списке + Любимые треки + Ваши сохранённые треки + Неизвестный исполнитель + Название трека + Имя исполнителя + + Выбрано + + Настройки + + Плагины + Язык и регион + Оформление + Воспроизведение + Загрузки + Кеширование + Компьютер + Обновления + Поддержка + О приложении + + Закрыть + Сохранить + Отмена + + Язык + Язык интерфейса приложения. Сейчас: %1$s + Как в системе + Страна + Страна для контента и доступности. Сейчас: %1$s + %1$s (%2$s) + + Тема + Как Spotube подстраивается под тему системы. Сейчас: %1$s + Светлая + Тёмная + Системная + + Поверхность + Фоновый материал приложения. Сейчас: %1$s + Вода + Лёд + Хром + Акрил + Стекло + + Акцентный цвет + Текущая палитра: %1$s + Акцентный цвет + Выберите акцентную палитру и посмотрите, как она выглядит в светлой и тёмной темах. + Светлая + Тёмная + Предпросмотр + Кнопка + Зелёный гоблин + Электрик-фиолетовый + Океанский циан + Оранжевый закат + Розовый сад + Полночный синий + Металлик-графит + + Внешний вид + Материал поверхности и акцент. Сейчас: %1$s · %2$s + Внешний вид + Выберите материал поверхности, а затем подходящий к нему акцент. Все варианты — продуманные сочетания. + Поверхность + Акцент + Вторичная + + Палитра + Акцентная палитра Millennium. Сейчас: %1$s + Палитра + Выберите акцентную палитру Millennium и посмотрите её в светлой и тёмной темах. + Палитра + Палитра Millennium и акцент. Сейчас: %1$s + Синяя + Оранжевая + Лаймовая + + Своя папка для загрузок + Текущая папка: %1$s + Текущая папка: стандартная папка «Музыка» + Оставьте пустым, чтобы использовать стандартную папку «Музыка». + C:\Music\Spotube + Папки с локальной музыкой + Дополнительные папки для сканирования на компьютере. Добавлено: %1$d + Дополнительные папки ещё не добавлены + Дополнительные папки для вашей локальной медиатеки. + Управлять + Добавить папку + Удалить папку + Дополнительные папки не добавлены. + Убрать %1$s из списка сканирования + Папки загрузок и музыкального кеша добавляются автоматически. + Папка загрузок добавляется автоматически. + Папка музыкального кеша добавляется автоматически. + Сохранение… + Не удалось сохранить папки. Изменения не потеряны — попробуйте ещё раз. + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Формат загрузки музыки + Предпочтительный кодек и контейнер для загрузок. Сейчас: %1$s + Качество загрузки музыки + + Формат стриминга музыки + Предпочтительный кодек и контейнер для стриминга. Сейчас: %1$s + Качество стриминга музыки + Сейчас: %1$s + Кешировать музыку + Сохранять прослушанное аудио на устройстве для быстрого повторного воспроизведения. + Папка кеша + Сейчас: %1$s + Сейчас: стандартный кеш приложения + Лимит размера кеша + Без ограничений + Текущий лимит: %1$d MB + Укажите максимальный размер кеша в MB. Введите 0 или оставьте пустым, чтобы снять ограничение. + 500 + Размер должен быть 0 или положительным числом. + Бесконечное воспроизведение + Продолжать воспроизведение рекомендованными треками, когда очередь закончится. + Включить Connect + Предоставлять удалённое управление воспроизведением через Spotube Connect. + Порт прокси-сервера воспроизведения + Разрешить удалённое управление + Разрешить другим устройствам в той же сети управлять воспроизведением после подключения. + Имя устройства для удалённого управления + Имя, которое видят другие устройства при поиске. Сейчас: %1$s + Задайте понятное имя, которое будут видеть другие устройства в вашей сети при обнаружении этого Spotube. + например, Spotube в гостиной + имя хоста устройства + Порт прокси-сервера воспроизведения. Сейчас: %1$d + Укажите порт от 1 до 65535. + 14769 + Введите целое число. + Порт должен быть от 1 до 65535. + + Чёрный список + Управление заблокированными треками и исполнителями + + Сворачивать в трей + Оставлять Spotube в системном трее при сворачивании окна. + Статус в Discord + Показывать в Discord, что сейчас играет (на компьютере). + + Автоматически проверять обновления + Искать новые версии Spotube в фоновом режиме. + + Управление плагинами + Управляйте своими плагинами + Плагин по умолчанию: %1$s + Метаданные + Аудио + Тексты песен + Скробблинг + Плагин не выбран + Изменить + Выбрать + Выбран + Сбросить выбор + Нет доступных плагинов + Плагин %1$s + Сбросить + + Настроить + Менеджер плагинов + Введите URL + URL должен начинаться с http:// или https:// + Не удалось скачать плагин + Плагины ещё не установлены + Вставьте ссылку для загрузки или выберите файл выше. + + %1$d плагин установлен + %1$d плагина установлено + %1$d плагинов установлено + %1$d плагина установлено + + Установка плагина + https://example.com/plugin.smplug + Скачать + Установить из файла (.smplug) + Активен + v%1$s + Встроенный + Удалить плагин + Войти + Выйти + Скачать по URL + Установить из файла + Установить + + • %1$s + Запрашиваемые разрешения + Этому плагину не нужны особые разрешения. + Установленный и новый + Установлен + Новый + API %1$s → %2$s + Постоянное хранилище + Чтение и запись данных на диск + Сетевые запросы + Отправка и получение данных через интернет + WebView + Показ веб-контента внутри приложения + Групповой джем + MQTT-брокер + Хост брокера + %1$s:%2$d + Порт брокера + Использовать TLS + Имя пользователя (необязательно) + Пароль (необязательно) + Префикс ID клиента + Используется для идентификации этого экземпляра Spotube + Шифровать подключение к брокеру + Проверьте текущие настройки брокера перед созданием комнаты. + Проверить подключение + Проверка… + Подключено за %1$d мс + Ошибка: %1$s + Временный брокер — настройте свой, чтобы размещать джемы самостоятельно + Подключение + Учётные данные + Идентификация + Диагностика + + + Групповой джем + Создать + Присоединиться + Создайте комнату и поделитесь её кодом с друзьями. + Создать комнату + Войти в комнату + Подключение… + Код комнаты + ABC123 + Брокер: %1$s + Настройте брокер в настройках, чтобы создать комнату или присоединиться к ней. + Настройки брокера + Подключено + Переподключение… + Копировать код + Скопировано! + Поделиться + Участники (%1$d) + Организатор + Слушатель + Ждём слушателей… + Удалить + Заблокировать + Управлять + Покинуть комнату + Не удалось подключиться + Закрыть + Не удалось создать джем + Не удалось присоединиться к джему + Введите код комнаты из шести букв и цифр. + Присоединяйся к моему джему в Spotube по коду: %1$s + + Перейти к альбому + Таймер сна + Сведения о треке + Воспроизвести + Пауза + Сейчас ничего не играет + Добавить в очередь + + Пропустить + Обновить + Доступен Spotube %1$s + Узнайте, что нового в этой версии, на странице загрузок Spotube. + Доступен %1$s %2$s + Установлена версия: %1$s + Узнайте, что нового в этом обновлении, на странице плагина. + + Пропустить + Далее + Назад + Начать + Spotube + Музыка без лишних условий. + Плеер с открытым исходным кодом для целого мира музыки.\nИсследуйте его. Сделайте его своим. + Помогите проекту жить. + Spotube создают люди, а не бизнес на подписках. Если он стал частью вашей жизни, даже небольшая поддержка поможет нам уделять ему то внимание, которого он заслуживает.\n\nСпасибо, что находите место для независимого софта. + Начните с плагина каталога, чтобы находить треки и плейлисты. Выберите аудиоплагин для их воспроизведения. Тексты песен и история прослушиваний — по желанию. Найдите плагин ниже, проверьте его разрешения, установите и выберите здесь. + Обзор плагинов + Ваши источники + Повторить + Другие плагины + Новых плагинов не найдено. Плагин также можно установить по ссылке или из файла .smplug выше. + Каталог плагинов сейчас недоступен. Повторите попытку или установите плагин по ссылке или из файла. + Добро пожаловать + Поддержка + Плагины + Руководство + + Как пользоваться Spotube + Четыре простых шага — и музыка уже играет. + Вход + Подключите аккаунт Spotify или выберите свой плагин каталога. + Ищите и слушайте + Найдите любой трек, альбом или исполнителя и нажмите «Воспроизвести». + Слушайте офлайн + Скачивайте треки, чтобы слушать их без интернета. + Следите за текстом + Открывайте синхронизированный текст в плеере во время воспроизведения. + + + Ваша музыка. Ваши источники. Ваши правила. + Бесплатный музыкальный плеер с открытым исходным кодом от сообщества, которое просто любит музыку. + Открытый код + Без рекламы + Без телеметрии + На плагинах + Поехали + + + Нас поддерживают слушатели + Создан с любовью. Живёт благодаря вам. + Spotube на 100% бесплатный, свободный и открытый — без рекламы, подписок и слежки. Каждая функция существует благодаря таким людям, как вы, которые помогают проекту развиваться. + Patreon + Ежемесячная поддержка + Поддержите постоянную разработку и инфраструктуру + Buy Me a Coffee + Разовый кофе + Взбодрите ночную сессию программирования небольшим донатом + Open Collective + Фонд сообщества + Прозрачное финансирование под управлением сообщества + От всего сердца спасибо, что верите в открытую музыку. + Поддержка полностью добровольная — Spotube в любом случае останется бесплатным. + Поддержать + Открыть %1$s + + + Поддержать Spotube + Поддержите Spotube + Spotube бесплатный, свободный и открытый — без рекламы, подписок и слежки. Если он приносит вам радость, поддержите проект. + Эти напоминания можно отключить в разделе «Настройки → Поддержка». + Может, позже + Напоминания о поддержке + Раз в 7 дней ненавязчиво напоминать о поддержке проекта. + + + Модульная архитектура + Выберите, откуда брать музыку. + В Spotube каталог, стриминг аудио и синхронизированные тексты работают отдельно. Настройте движки ниже или меняйте их в любой момент. + Движок метаданных + Отвечает за поиск музыки, списки треков и плейлисты. + Аудиодвижок + Получает и декодирует аудиопотоки для воспроизведения. + Движок текстов + Показывает синхронизированные караоке-тексты в реальном времени. + Движок скробблинга + Записывает историю прослушиваний в Last.fm или ListenBrainz. + Активен + Встроенный + Готов + Необязательно + Выберите движок + Установленные движки + Сделать основным + NewPipe YouTube и LRCLib Lyrics встроены и работают сразу после установки. + Управлять плагинами сообщества и добавлять новые можно в любой момент в разделе «Настройки → Плагины». + + + Ретромодерн-архитектура + Чистый Hi-Fi без посредников + Вот как Spotube организует децентрализованное воспроизведение прямо на вашем устройстве. + НАПРЯМУЮ, P2P • 256 KBPS OPUS + Синий час + Spotube · демонстрация плеера + Немного музыки для мгновений между делами. + 1. Децентрализованный поиск + Запросы идут напрямую в выбранный плагин каталога. Spotube никогда не анализирует ваши вкусы. + 2. Прямое получение потока + Аудио поступает напрямую с серверов контента на ваше устройство. Никаких прокси и удалённого сбора данных. + 3. Локальная обработка и воспроизведение + Декодирование и кеширование на устройстве, эквалайзер и загрузка для офлайн-прослушивания по желанию. + 4. Синхронные тексты и джем + Караоке-тексты следуют за воспроизведением, а ещё есть удалённое управление по LAN и групповой джем. + Никаких серверов-посредников. Воспроизведение происходит прямо на вашем устройстве — приватность по умолчанию. + + + Выберите устройство + %1$s: «%2$s» + Воспроизвести + Добавить в очередь + Воспроизвести следующим + Это устройство + На этом устройстве + Удалённое устройство + Удалённое устройство (%1$s) + На подключённом устройстве + + + Не удалось подключиться + Соединение потеряно + Не удалось отправить + Ошибка поиска: %1$s + Не удалось подключиться: %1$s + + + Устройства + Обновить + Подключено к %1$s:%2$d + Отключиться + Подключение... + Поиск устройств в сети... + Устройства не найдены + Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление». + Неизвестное устройство + Определение адреса... + ID: %1$s... + + + Удалённое управление + Отключено + Ошибка подключения: %1$s + Обложка альбома + Неизвестный трек + Неизвестный исполнитель + Перемотка + Перемешать + Предыдущий + Следующий + Режим повтора + Громкость + Удалить из очереди + Другие действия + + + Где воспроизвести? + У вас активный джем. Выберите, где воспроизвести эту подборку. + Воспроизвести здесь + Предложить в джем + + + Брокер для джема не настроен + Не указан хост брокера + Не удалось подключиться + Соединение потеряно + + + Брокер + anonymous + + + Добавлено в очередь джема + + %1$d трек добавлен в очередь джема + %1$d трека добавлено в очередь джема + %1$d треков добавлено в очередь джема + %1$d трека добавлено в очередь джема + + + + Запрос на удалённое управление + «%1$s» хочет управлять воспроизведением на этом устройстве. + Отклонить + Разрешить + Разрешать всегда + + + Результаты поиска + Все + Треки + Пользователи + Нет доступных типов поиска + Начните вводить запрос + Начните вводить, чтобы найти треки + Ничего не найдено + Показать все + Поиск + Загрузка треков... + Загрузка альбомов... + Загрузка исполнителей... + Загрузка плейлистов... + Загрузка пользователей... + Альбомы не найдены + Исполнители не найдены + Плейлисты не найдены + Пользователи не найдены + Роемся в ящиках с пластинками... + Ищем по всей вселенной... + Прогреваем колонки... + Сдуваем пыль с винила... + Советуемся с богами музыки... + Настраиваем антенны... + Перебираем пластинки... + Не удалось загрузить возможности поиска + Не удалось выполнить поиск + Не удалось найти треки + Не удалось найти альбомы + Не удалось найти исполнителей + Не удалось найти плейлисты + Не удалось найти пользователей + + + Обзор + + + Синхронный + Простой + Синхронизированный текст недоступен + Текст недоступен + + + %1$s в %2$s + %1$d kbps + %1$s kHz • %2$d кан. + Плагин метаданных не выбран + + + Назад + Повторить + Закрыть окно + Загрузка... + Неизвестно + Неизвестная ошибка + + + Пользователь + Плейлист + + + Нам очень жаль + Что-то пошло не так. Нам важно ваше впечатление, и мы делаем всё, чтобы это исправить. Попробуйте ещё раз или посмотрите подробности ошибки ниже. + Подробности ошибки + Копировать в буфер обмена + Показать полный журнал + Полный журнал ошибки + + + Перемешать + Перемешать и слушать + Подписаться + Отписаться + Изменить плейлист + Автор: %1$s + + + Действия с треком + Добавить в джем + Запустить радио + Воспроизвести следующим + Сделать следующим + Удалить из очереди + Удалить из избранного + Добавить в избранное + Добавить в плейлист + Скачать + Поделиться + + + Исходный порядок + Название + Исполнитель + Альбом + Длительность + Сортировка + Треки не найдены + Фильтр + Действия с выбранными + Скачать все + Скачать (%1$d) + Добавить все в очередь + Добавить в очередь (%1$d) + Воспроизвести все следующими + Воспроизвести следующими (%1$d) + Добавить все в плейлист + Добавить в плейлист (%1$d) + Добавить все в джем + Добавить в джем (%1$d) + и ещё %1$d + + + Нет URL для загрузки + Назад по истории браузера + Вперёд по истории браузера + + + Фильтр чёрного списка... + Чёрный список пуст + В чёрном списке нет совпадений с «%1$s» + Треки + Трек + Добавить в чёрный список + Удалить из чёрного списка + + + Нет метаданных трека + Не удалось получить URL потока + Не удалось получить URL потока. Сначала попробуйте воспроизвести трек. + + + Сохранённые треки + Вы + + + Вы подписаны + Популярные треки + Слушать популярные треки + Добавить популярные треки в очередь + Похожие исполнители + Рекомендуемые плейлисты + Не удалось загрузить страницу исполнителя + Число подписчиков недоступно + Подписчиков: %1$s тыс. + Подписчиков: %1$s млн + Подписчиков: %1$s млрд + + %1$d подписчик + %1$d подписчика + %1$d подписчиков + %1$d подписчика + + + + Нравится + Больше не нравится + Название трека-заглушки + Исполнители-заглушки + Неизвестный альбом + Перемотка + Громкость + Включить перемешивание + Выключить перемешивание + Предыдущий + Следующий + Воспроизвести или приостановить + Повтор выключен + Повтор трека + Повтор всех + Выключить звук + Включить звук + Другие действия + Параметры плеера + Свернуть плеер + Развернуть текст + Сейчас играет + Текст + Альтернативный источник + Альтернативный источник + Скачать + Скачано + Загрузка... + Уже скачано + Повторная загрузка... + Загрузка: %1$s + Воспроизведение остановится через %1$s. + Поставить воспроизведение на паузу через выбранное число минут. + Минуты + От 1 до 600 минут + Отменить таймер + Запустить + URL воспроизведения был получен с другими настройками стриминга. Выделенный поток соответствует текущим предпочтениям; активный URL обновится при следующем запросе потока. + Подобранный источник аудио + Загрузка подобранного источника… + Удалённый источник не нужен, пока воспроизводится локальный кешированный файл. + Подходящий источник недоступен. + Для этого трека нет кешированного подобранного источника аудио. + Сведения о потоках ещё не закешированы. Сначала воспроизведите трек, чтобы загрузить их. + Не удалось загрузить сведения о треке. + Доступные потоки + Этот трек воспроизводится из локального кеша. Варианты потоков сейчас не используются. + Загрузка потоков… + Для этого источника нет доступных потоков. + Выбранный поток + Соответствует вашим настройкам формата и качества стриминга + Активный URL воспроизведения + Из локального кеша + Трек из метаданных + Название + ID + Альбом + Точность совпадения + Внешний URI + ID альбома + Тип альбома + Дата выхода альбома + Треков в альбоме + Жанры альбома + Длительность + Номер трека + Номер диска + Ненормативный контент + Популярность + ISRC + URL миниатюр + + + Показать или скрыть боковую панель + + + Альтернативные источники + Сейчас ничего не играет + Поиск альтернативных источников... + Альтернативные источники не найдены + Совпадение: %1$d% + Текущий источник + + + Исключить + Удалить участника из сессии + Исключить и запретить возвращаться + Удалить предложения + Удалить все треки, которые участник добавил в очередь + Изменить порядок + Удалить из очереди + %1$s • Добавил(а) %2$s + + + Неизвестно + Загрузка плейлиста... + Добавить треки + Добавить треки в плейлист + Поиск треков... + Поиск + Треки не найдены + Найдите треки, чтобы добавить их + Готово + Добавить + Добавлено + + + Загрузка альбома... + + + Добавить в ваш плейлист + Поиск + Поиск по вашим плейлистам... + Загрузка ваших плейлистов... + У вас пока нет своих плейлистов.\nСоздайте плейлист, чтобы добавлять в него треки. + Новый плейлист + Изменить плейлист + Создать + Название + Название плейлиста + Описание + Описание (необязательно) + Открытый + Этот плейлист виден всем + Совместный + Другие могут добавлять треки в этот плейлист + Обложка + Обложка плейлиста + Нет изображения + Сменить изображение + Выбрать изображение + + + Установить + Заменить + В zip-архиве не найден plugin.json + В zip-архиве не найден manifest.zipline.json + Неверный формат plugin.json: %1$s + Не удалось прочитать плагин: %1$s + Не удалось установить плагин: %1$s + Доступна новая версия %1$s. Подтвердите обновление перед установкой. + %1$s уже установлен. Подтвердите замену перед установкой. + Встроенные плагины уже включены, их нельзя добавить повторно. + Встроенные плагины нельзя удалить. + %1$s указывает недопустимую apiVersion (%2$s). Ожидается семантическая версия, совместимая с %3$s. + Недопустимая версия API плагинов Spotube: %1$s. + %1$s рассчитан на API плагинов %2$s, а Spotube поддерживает несовместимую версию API %3$s. Установите плагин, собранный для той же основной версии API. + + + Несовместимый API плагина + Установить плагин? + %1$s будет добавлен к установленным плагинам. + Обновить плагин? + Установлена более старая версия (%1$s). Обновить до %2$s? + Заменить установленный плагин? + %1$s использует недопустимую семантическую версию (%2$s), поэтому её нельзя сравнить как обновление. Всё равно заменить установленный плагин? + Установленную версию (%1$s) нельзя сравнить по правилам семантического версионирования. Заменить её на %2$s? + Версия %1$s уже установлена. Заменить существующий плагин новой копией? + Установленная версия %1$s новее предложенной версии %2$s. Всё равно заменить? + Заменить установленный плагин новой копией? + + + %1$s: + Версия + Версия API + Автор + Лицензия + Разрешения + Функции + Репозиторий + Контакты + Сообщить об ошибке + Поддержать %1$s + + + Официальный + Проверенный + Звёзды на GitHub + URL репозитория на GitHub + + + Релизы не найдены + Выберите релиз + Выберите релиз + Предварительный релиз + Примечания к выпуску + + + Главная + Поиск + На устройстве + Подключения + Устройства + + + Скрыть окно + Показать окно + Следующий трек + Предыдущий трек + Перемешать + Повтор + Повтор: ВЫКЛ + Повтор: ТРЕК + Повтор: ВСЕ + Громкость: %1$d% + Громкость + + Громкость - + Выключить звук + Включить звук + Нравится + Больше не нравится + Выход + + + Свернуть + Развернуть + Восстановить + Закрыть + + + Служба воспроизведения + Воспроизведение + Управление воспроизведением + + + Обзор + Сейчас играет + Рекомендуемое + Сохранённые треки + Популярные треки + diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml new file mode 100644 index 00000000..dffc10f4 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -0,0 +1,932 @@ + + + + คิว + ล้างคิว + กรองคิว... + ไม่มีรายการในคิว + + ค้นหาเพลง ศิลปิน อัลบั้ม... + ล้างประวัติทั้งหมด + ค้นหา... + + + คลังเพลงของคุณ + คลังเพลง + เพลย์ลิสต์ + อัลบั้ม + ศิลปิน + เพลงในเครื่อง + ดาวน์โหลด + ค้นหาเพลย์ลิสต์ที่บันทึกไว้… + ค้นหาอัลบั้มที่บันทึกไว้… + ค้นหาศิลปินที่บันทึกไว้… + ค้นหาโฟลเดอร์หรือเพลง… + กรองคลังเพลง + ปิดตัวกรอง + สร้างเพลย์ลิสต์ + รีเฟรชเพลงในเครื่อง + เปลี่ยนเป็นมุมมองตาราง + เปลี่ยนเป็นมุมมองรายการ + ไม่มีเพลย์ลิสต์ที่บันทึกไว้ + ไม่มีเพลย์ลิสต์ที่ตรงกับ “%1$s” + ไม่มีอัลบั้มที่บันทึกไว้ + ไม่มีอัลบั้มที่ตรงกับ “%1$s” + ไม่มีศิลปินที่บันทึกไว้ + ไม่มีศิลปินที่ตรงกับ “%1$s” + + + โฟลเดอร์ + โฟลเดอร์ + %1$s • %2$s + + %1$d เพลง + + + %1$d โฟลเดอร์ + + กลับไปที่โฟลเดอร์ + เล่นโฟลเดอร์ + ต้องได้รับสิทธิ์เข้าถึงไฟล์เสียง + อนุญาตการเข้าถึงไฟล์เสียงเพื่อเรียกดูเพลงในอุปกรณ์ของคุณ + อนุญาตการเข้าถึง + ไม่พบโฟลเดอร์ในเครื่อง + ไม่มีเพลงในโฟลเดอร์นี้ + ไม่มีโฟลเดอร์หรือเพลงที่ตรงกัน + รีเฟรชสื่อในเครื่องไม่สำเร็จ + + + กำลังดำเนินการ %1$d • เสร็จสิ้น %2$d + ล้างรายการที่เสร็จแล้ว + ยังไม่มีการดาวน์โหลด + กำลังดาวน์โหลด %1$d% + อยู่ในคิว + เสร็จสิ้น + ยกเลิกแล้ว + ล้มเหลว + ล้มเหลว: %1$s + ยกเลิกการดาวน์โหลด + ลองดาวน์โหลดอีกครั้ง + ลบการดาวน์โหลด + + + ศิลปิน + อยู่ในบัญชีดำ + เพลงที่ถูกใจ + เพลงที่คุณบันทึกไว้ + ไม่ทราบศิลปิน + ชื่อเพลง + ชื่อศิลปิน + + เลือกแล้ว + + การตั้งค่า + + ปลั๊กอิน + ภาษาและภูมิภาค + รูปลักษณ์ + การเล่น + การดาวน์โหลด + แคช + เดสก์ท็อป + อัปเดต + สนับสนุน + เกี่ยวกับ + + ปิด + บันทึก + ยกเลิก + + ภาษา + ภาษาของอินเทอร์เฟซแอป ปัจจุบัน: %1$s + ค่าเริ่มต้นของระบบ + ประเทศ + ประเทศสำหรับเนื้อหาและความพร้อมใช้งาน ปัจจุบัน: %1$s + %1$s (%2$s) + + ธีม + เลือกว่าจะให้ Spotube แสดงผลตามระบบอย่างไร ปัจจุบัน: %1$s + สว่าง + มืด + ตามระบบ + + พื้นผิว + วัสดุพื้นหลังของแอป ปัจจุบัน: %1$s + สายน้ำ + น้ำแข็ง + โครม + อะคริลิก + กระจก + + สีเน้น + ชุดสีปัจจุบัน: %1$s + สีเน้น + เลือกชุดสีเน้นและดูตัวอย่างทั้งในธีมสว่างและธีมมืด + สว่าง + มืด + ตัวอย่าง + ปุ่ม + กรีนก็อบลิน + ม่วงไฟฟ้า + ฟ้าครามมหาสมุทร + ส้มอัสดง + สวนกุหลาบ + น้ำเงินเที่ยงคืน + เทาเมทัลลิก + + รูปลักษณ์และความรู้สึก + วัสดุพื้นผิวและสีเน้น ปัจจุบัน: %1$s · %2$s + รูปลักษณ์และความรู้สึก + เลือกวัสดุพื้นผิว แล้วเลือกสีเน้นที่เข้ากัน ทุกตัวเลือกเป็นชุดที่คัดสรรมาแล้ว + พื้นผิว + สีเน้น + รอง + + ชุดสี + ชุดสีเน้น Millennium ปัจจุบัน: %1$s + ชุดสี + เลือกชุดสีเน้น Millennium และดูตัวอย่างทั้งในโหมดสว่างและมืด + ชุดสี + ชุดสีและสีเน้น Millennium ปัจจุบัน: %1$s + น้ำเงิน + ส้ม + เขียวมะนาว + + กำหนดโฟลเดอร์ดาวน์โหลดเอง + โฟลเดอร์ปัจจุบัน: %1$s + โฟลเดอร์ปัจจุบัน: โฟลเดอร์เพลงเริ่มต้น + เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น + C:\Music\Spotube + โฟลเดอร์สื่อในเครื่อง + โฟลเดอร์ที่กำหนดเองเพื่อสแกนบนเดสก์ท็อป เพิ่มแล้ว: %1$d + ยังไม่ได้เพิ่มโฟลเดอร์ที่กำหนดเอง + โฟลเดอร์ที่กำหนดเองสำหรับคลังเพลงในเครื่องของคุณ + จัดการ + เพิ่มโฟลเดอร์ + ลบโฟลเดอร์ + ยังไม่ได้เพิ่มโฟลเดอร์ที่กำหนดเอง + ลบ %1$s ออกจากรายการสแกน + โฟลเดอร์ดาวน์โหลดและโฟลเดอร์แคชเพลงจะถูกรวมโดยอัตโนมัติ + โฟลเดอร์ดาวน์โหลดของคุณจะถูกรวมโดยอัตโนมัติ + โฟลเดอร์แคชเพลงจะถูกรวมโดยอัตโนมัติ + กำลังบันทึก… + บันทึกโฟลเดอร์ไม่ได้ การเปลี่ยนแปลงของคุณยังอยู่ โปรดลองอีกครั้ง + + %1$d โฟลเดอร์ + + รูปแบบเพลงที่ดาวน์โหลด + โคเดกและคอนเทนเนอร์ที่ต้องการสำหรับการดาวน์โหลด ปัจจุบัน: %1$s + คุณภาพเพลงที่ดาวน์โหลด + + รูปแบบการสตรีมเพลง + โคเดกและคอนเทนเนอร์ที่ต้องการสำหรับการสตรีมเพลง ปัจจุบัน: %1$s + คุณภาพการสตรีมเพลง + ปัจจุบัน: %1$s + เปิดใช้แคชเพลง + จัดเก็บเสียงที่สตรีมไว้ในเครื่องเพื่อให้เล่นซ้ำได้เร็วขึ้น + โฟลเดอร์แคช + ปัจจุบัน: %1$s + ปัจจุบัน: แคชเริ่มต้นของแอป + ขีดจำกัดขนาดแคช + ไม่จำกัด + ขีดจำกัดปัจจุบัน: %1$d MB + กำหนดขนาดแคชสูงสุดเป็น MB ใส่ 0 หรือเว้นว่างไว้เพื่อไม่จำกัด + 500 + ขนาดต้องเป็น 0 หรือจำนวนบวก + เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด + เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด + เปิดใช้ Connect + เปิดให้ควบคุมการเล่นจากระยะไกลผ่านฟีเจอร์ Spotube Connect + พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น + อนุญาตการควบคุมระยะไกล + ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ + ชื่ออุปกรณ์สำหรับควบคุมระยะไกล + ชื่อที่อุปกรณ์อื่นจะเห็นเมื่อค้นหาอุปกรณ์นี้ ปัจจุบัน: %1$s + เลือกชื่อที่จำง่ายเพื่อแสดงให้อุปกรณ์อื่นในเครือข่ายของคุณเห็นเมื่อค้นพบ Spotube เครื่องนี้ + เช่น Spotube ห้องนั่งเล่น + ชื่อโฮสต์ของอุปกรณ์ + พอร์ตที่เซิร์ฟเวอร์พร็อกซีการเล่นใช้ ปัจจุบัน: %1$d + เลือกพอร์ตระหว่าง 1 ถึง 65535 + 14769 + ใส่จำนวนเต็มที่ถูกต้อง + พอร์ตต้องอยู่ระหว่าง 1 ถึง 65535 + + บัญชีดำ + จัดการเพลงและศิลปินในบัญชีดำ + + ย่อไปที่ถาดระบบ + ให้ Spotube ทำงานต่อในถาดระบบเมื่อย่อหน้าต่าง + Discord Rich Presence + แสดงสถานะการเล่นปัจจุบันของคุณใน Discord บนเดสก์ท็อป + + ตรวจหาอัปเดตโดยอัตโนมัติ + ค้นหา Spotube เวอร์ชันใหม่ในเบื้องหลัง + + จัดการปลั๊กอิน + จัดการปลั๊กอินของคุณ + ปลั๊กอิน%1$sเริ่มต้น + เมทาดาทา + เสียง + เนื้อเพลง + Scrobble + ยังไม่ได้เลือกปลั๊กอิน + เปลี่ยน + เลือก + เลือกแล้ว + ล้างการเลือก + ไม่มีปลั๊กอินให้ใช้ + ปลั๊กอิน %1$s + ล้าง + + กำหนดค่า + ตัวจัดการปลั๊กอิน + โปรดใส่ URL + URL ต้องขึ้นต้นด้วย http:// หรือ https:// + ดาวน์โหลดปลั๊กอินไม่สำเร็จ + ยังไม่ได้ติดตั้งปลั๊กอิน + วาง URL สำหรับดาวน์โหลดหรือเลือกไฟล์ด้านบน + + ติดตั้งปลั๊กอินแล้ว %1$d รายการ + + ติดตั้งปลั๊กอิน + https://example.com/plugin.smplug + ดาวน์โหลด + ติดตั้งจากไฟล์ (.smplug) + ใช้งานอยู่ + v%1$s + ในตัว + ลบปลั๊กอิน + เข้าสู่ระบบ + ออกจากระบบ + ดาวน์โหลดจาก URL + ติดตั้งจากไฟล์ + ติดตั้ง + + • %1$s + สิทธิ์ที่ร้องขอ + ปลั๊กอินนี้ไม่ต้องใช้สิทธิ์พิเศษ + ที่ติดตั้งอยู่เทียบกับที่ให้มา + ติดตั้งอยู่ + ที่ให้มา + API %1$s → %2$s + พื้นที่จัดเก็บถาวร + อ่านและเขียนข้อมูลลงดิสก์ + คำขอเครือข่าย + ส่งและรับข้อมูลผ่านอินเทอร์เน็ต + WebView + แสดงเนื้อหาเว็บภายในแอป + แจมกลุ่ม + MQTT Broker + โฮสต์ของ Broker + %1$s:%2$d + พอร์ตของ Broker + ใช้ TLS + ชื่อผู้ใช้ (ไม่บังคับ) + รหัสผ่าน (ไม่บังคับ) + คำนำหน้า Client ID + ใช้เพื่อระบุ Spotube เครื่องนี้ + เข้ารหัสการเชื่อมต่อกับ Broker + ตรวจสอบการตั้งค่า Broker ปัจจุบันก่อนเริ่มห้อง + ทดสอบการเชื่อมต่อ + กำลังทดสอบ… + เชื่อมต่อแล้วใน %1$d ms + ล้มเหลว: %1$s + Broker ตัวอย่าง — กำหนดค่าของคุณเองเพื่อโฮสต์เอง + การเชื่อมต่อ + ข้อมูลเข้าสู่ระบบ + ตัวตน + การวินิจฉัย + + + แจมกลุ่ม + สร้าง + เข้าร่วม + สร้างห้อง แล้วแชร์รหัสห้องให้เพื่อนของคุณ + สร้างห้อง + เข้าร่วมห้อง + กำลังเชื่อมต่อ… + รหัสห้อง + ABC123 + Broker: %1$s + ตั้งค่า Broker ในการตั้งค่าเพื่อสร้างหรือเข้าร่วมห้อง + การตั้งค่า Broker + เชื่อมต่อแล้ว + กำลังเชื่อมต่อใหม่… + คัดลอกรหัส + คัดลอกแล้ว! + แชร์ + ผู้เข้าร่วม (%1$d) + โฮสต์ + ผู้ฟัง + กำลังรอผู้ฟัง… + นำออก + แบน + จัดการ + ออกจากห้อง + เชื่อมต่อไม่ได้ + ปิด + สร้างเซสชันแจมไม่สำเร็จ + เข้าร่วมเซสชันแจมไม่สำเร็จ + ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข + มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s + + ไปที่อัลบั้ม + ตั้งเวลาปิด + รายละเอียดเพลง + เล่น + หยุดชั่วคราว + ไม่มีเพลงที่กำลังเล่น + เพิ่มลงในคิว + + ละเว้น + อัปเดต + Spotube %1$s พร้อมให้ใช้งานแล้ว + ดูว่ามีอะไรใหม่ในเวอร์ชันนี้ได้ที่หน้าดาวน์โหลดของ Spotube + %1$s %2$s พร้อมให้ใช้งานแล้ว + ติดตั้งอยู่ในปัจจุบัน: %1$s + ดูว่ามีอะไรใหม่ในอัปเดตนี้ได้จากหน้าของปลั๊กอิน + + ข้าม + ถัดไป + ย้อนกลับ + เริ่มต้นใช้งาน + Spotube + ดนตรีที่ไร้พันธนาการ + เครื่องเล่นโอเพนซอร์สสำหรับโลกแห่งดนตรี\nให้คุณสำรวจ ให้คุณสร้างในแบบของคุณเอง + ช่วยให้โปรเจกต์นี้อยู่ต่อไป + Spotube สร้างขึ้นโดยผู้คน ไม่ใช่ธุรกิจแบบสมัครสมาชิก หากแอปนี้กลายเป็นส่วนหนึ่งในชีวิตประจำวันของคุณ การสนับสนุนเพียงเล็กน้อยจะช่วยให้เราดูแลแอปได้อย่างที่ควรเป็น\n\nขอบคุณที่เปิดพื้นที่ให้ซอฟต์แวร์อิสระ + เริ่มต้นด้วยปลั๊กอินแคตตาล็อกเพื่อค้นหาเพลงและเพลย์ลิสต์ เลือกปลั๊กอินเสียงเพื่อเล่นเพลง ส่วนเนื้อเพลงและประวัติการฟังเป็นตัวเลือกเสริม ค้นพบปลั๊กอินด้านล่าง ตรวจสอบสิทธิ์ ติดตั้ง แล้วเลือกใช้ที่นี่ + ค้นพบปลั๊กอิน + แหล่งที่มาของคุณ + ลองอีกครั้ง + ปลั๊กอินเพิ่มเติม + ไม่พบปลั๊กอินใหม่ คุณยังติดตั้งจากลิงก์หรือไฟล์ .smplug ด้านบนได้ + ไม่สามารถค้นหาปลั๊กอินได้ในขณะนี้ ลองอีกครั้ง หรือติดตั้งจากลิงก์หรือไฟล์ + ยินดีต้อนรับ + สนับสนุน + ปลั๊กอิน + คู่มือ + + วิธีใช้ Spotube + แค่ 4 ขั้นตอนง่าย ๆ ก็เริ่มฟังได้เลย + เข้าสู่ระบบ + เชื่อมต่อบัญชี Spotify ของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง + ค้นหาและเล่น + ค้นหาเพลง อัลบั้ม หรือศิลปิน แล้วกดเล่น + ฟังแบบออฟไลน์ + ดาวน์โหลดเพลงเพื่อฟังต่อได้แม้ไม่มีการเชื่อมต่อ + ร้องตามเนื้อเพลง + เปิดเนื้อเพลงแบบซิงค์จากเครื่องเล่นขณะที่เพลงกำลังเล่น + + + เพลงของคุณ แหล่งที่มาของคุณ กฎของคุณ + เครื่องเล่นเพลงฟรีและโอเพนซอร์ส สร้างโดยชุมชนที่รักในเสียงเพลง + โอเพนซอร์ส + ไม่มีโฆษณา + ไม่มีการเก็บข้อมูล + ขับเคลื่อนด้วยปลั๊กอิน + ไปกันเลย + + + ผู้สนับสนุนคือแรงผลักดันของเรา + สร้างด้วยใจ อยู่ได้ด้วยคุณ + Spotube ฟรี เสรี และโอเพนซอร์ส 100% — ไม่มีโฆษณา ไม่มีการสมัครสมาชิก ไม่มีการติดตาม ทุกฟีเจอร์มีอยู่ได้เพราะคนอย่างคุณเลือกที่จะช่วยให้การพัฒนาเดินหน้าต่อไป + Patreon + ผู้สนับสนุนรายเดือน + สนับสนุนการพัฒนาและโครงสร้างพื้นฐานอย่างต่อเนื่อง + Buy Me a Coffee + กาแฟครั้งเดียว + เติมพลังให้การเขียนโค้ดยามดึกด้วยทิปเล็ก ๆ + Open Collective + กองทุนชุมชน + การสนับสนุนทางการเงินที่โปร่งใสและบริหารโดยชุมชน + ขอบคุณจากใจที่เชื่อมั่นในดนตรีแบบเปิด + การสนับสนุนเป็นทางเลือกโดยสมบูรณ์ — Spotube ยังคงฟรีไม่ว่าอย่างไร + สนับสนุน + เปิด %1$s + + + สนับสนุน Spotube + สนับสนุน Spotube + Spotube ฟรี เสรี และโอเพนซอร์ส — ไม่มีโฆษณา ไม่มีการสมัครสมาชิก ไม่มีการติดตาม หากแอปนี้ทำให้คุณมีความสุข ลองพิจารณาสนับสนุนโปรเจกต์นี้ + คุณปิดการแจ้งเตือนเหล่านี้ได้ที่ การตั้งค่า → สนับสนุน + ไว้ทีหลัง + การแจ้งเตือนให้สนับสนุน + แสดงการแจ้งเตือนเบา ๆ ให้สนับสนุนโปรเจกต์ทุก 7 วัน + + + สถาปัตยกรรมแบบโมดูล + เลือกได้ว่าเพลงของคุณมาจากไหน + Spotube แยกการค้นหาแคตตาล็อก การสตรีมเสียง และเนื้อเพลงแบบซิงค์ออกจากกัน กำหนดค่าเอนจินของคุณด้านล่างหรือเปลี่ยนได้ทุกเมื่อ + เอนจินเมทาดาทา + ขับเคลื่อนการค้นพบเพลง การค้นหา รายชื่อเพลง และเพลย์ลิสต์ + เอนจินเสียง + ดึงและถอดรหัสสตรีมเสียงที่จะเล่น + เอนจินเนื้อเพลง + สตรีมเนื้อเพลงคาราโอเกะที่ซิงค์ตามเวลาแบบเรียลไทม์ + เอนจิน Scrobble + บันทึกประวัติการฟังไปยัง Last.fm หรือ ListenBrainz + ใช้งานอยู่ + ในตัว + พร้อม + ไม่บังคับ + เลือกเอนจิน + เอนจินที่ติดตั้งไว้ + ตั้งเป็นค่าเริ่มต้น + NewPipe YouTube และ LRCLib Lyrics มีมาในตัวและพร้อมสตรีมได้ทันที + จัดการหรือเพิ่มปลั๊กอินจากชุมชนได้ทุกเมื่อที่ การตั้งค่า → ปลั๊กอิน + + + สถาปัตยกรรมย้อนยุคผสมสมัยใหม่ + Hi-Fi บริสุทธิ์ ไม่มีคนกลาง + นี่คือวิธีที่ Spotube จัดการการเล่นแบบกระจายศูนย์บนเครื่องของคุณโดยตรง + เพียร์ทูเพียร์โดยตรง • 256 KBPS OPUS + Blue hour + Spotube · ตัวอย่างเครื่องเล่น + ดนตรีเล็ก ๆ สำหรับช่วงเวลาระหว่างทาง + 1. การค้นพบแบบกระจายศูนย์ + คำค้นหาส่งตรงไปยังปลั๊กอินแคตตาล็อกที่คุณเลือก Spotube ไม่เคยเก็บข้อมูลรสนิยมของคุณ + 2. ระบุสตรีมโดยตรง + เสียงสตรีมตรงจากเซิร์ฟเวอร์เนื้อหามายังเครื่องของคุณ ไม่ผ่านพร็อกซีหรือการเก็บข้อมูลจากระยะไกล + 3. DSP และการเล่นในเครื่อง + ถอดรหัสและแคชในเครื่อง พร้อมอีควอไลเซอร์และการดาวน์โหลดแบบออฟไลน์ (ไม่บังคับ) + 4. เนื้อเพลงแบบซิงค์และแจม + เนื้อเพลงคาราโอเกะเลื่อนตามเวลาที่เล่น พร้อมการควบคุมระยะไกลผ่าน LAN และแจมกลุ่ม + ไม่มีเซิร์ฟเวอร์คนกลาง การเล่นเกิดขึ้นบนอุปกรณ์ของคุณ — เป็นส่วนตัวตั้งแต่การออกแบบ + + + เลือกอุปกรณ์ + %1$s: “%2$s” + เล่น + เพิ่มลงในคิว + เล่นถัดไป + อุปกรณ์นี้ + บนอุปกรณ์นี้ + อุปกรณ์ระยะไกล + อุปกรณ์ระยะไกล (%1$s) + บนอุปกรณ์ที่เชื่อมต่ออยู่ + + + การเชื่อมต่อล้มเหลว + การเชื่อมต่อขาดหาย + ส่งไม่สำเร็จ + การค้นหาล้มเหลว: %1$s + เชื่อมต่อไม่สำเร็จ: %1$s + + + อุปกรณ์ + รีเฟรช + เชื่อมต่อกับ %1$s:%2$d แล้ว + ยกเลิกการเชื่อมต่อ + กำลังเชื่อมต่อ... + กำลังค้นหาอุปกรณ์ในเครือข่าย... + ไม่พบอุปกรณ์ + ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า + อุปกรณ์ที่ไม่รู้จัก + กำลังระบุ... + ID: %1$s... + + + รีโมตคอนโทรล + ยกเลิกการเชื่อมต่อแล้ว + ข้อผิดพลาดในการเชื่อมต่อ: %1$s + ปกอัลบั้ม + ไม่ทราบชื่อเพลง + ไม่ทราบศิลปิน + เลื่อนตำแหน่ง + สุ่มเพลง + ก่อนหน้า + ถัดไป + โหมดเล่นซ้ำ + ระดับเสียง + นำออกจากคิว + ตัวเลือกเพิ่มเติม + + + เล่นที่ไหน? + คุณมีเซสชันแจมที่ใช้งานอยู่ เลือกว่าจะเล่นคอลเลกชันนี้ที่ไหน + เล่นที่นี่ + เสนอให้แจม + + + ยังไม่ได้กำหนดค่า Broker สำหรับแจม + โฮสต์ของ Broker ว่างอยู่ + การเชื่อมต่อล้มเหลว + การเชื่อมต่อขาดหาย + + + Broker + anonymous + + + เพิ่มลงในคิวแจมแล้ว + + เพิ่ม %1$d เพลงลงในคิวแจมแล้ว + + + + คำขอควบคุมระยะไกล + “%1$s” ต้องการควบคุมการเล่นบนอุปกรณ์นี้ + ปฏิเสธ + อนุญาต + อนุญาตเสมอ + + + ผลการค้นหา + ทั้งหมด + เพลง + ผู้ใช้ + ไม่มีประเภทการค้นหาให้ใช้ + เริ่มพิมพ์เพื่อค้นหา + เริ่มพิมพ์เพื่อค้นหาเพลง + ไม่พบผลลัพธ์ + ดูทั้งหมด + กำลังค้นหา + กำลังโหลดเพลง... + กำลังโหลดอัลบั้ม... + กำลังโหลดศิลปิน... + กำลังโหลดเพลย์ลิสต์... + กำลังโหลดผู้ใช้... + ไม่พบอัลบั้ม + ไม่พบศิลปิน + ไม่พบเพลย์ลิสต์ + ไม่พบผู้ใช้ + กำลังคุ้ยกล่องแผ่นเสียง... + กำลังค้นหาทั่วจักรวาล... + กำลังวอร์มลำโพง... + กำลังปัดฝุ่นแผ่นไวนิล... + กำลังปรึกษาเทพแห่งดนตรี... + กำลังจูนเสาอากาศ... + กำลังพลิกหาแผ่นเสียง... + โหลดความสามารถในการค้นหาไม่สำเร็จ + ค้นหาไม่สำเร็จ + ค้นหาเพลงไม่สำเร็จ + ค้นหาอัลบั้มไม่สำเร็จ + ค้นหาศิลปินไม่สำเร็จ + ค้นหาเพลย์ลิสต์ไม่สำเร็จ + ค้นหาผู้ใช้ไม่สำเร็จ + + + เรียกดู + + + ซิงค์ + ธรรมดา + ไม่มีเนื้อเพลงแบบซิงค์ + ไม่มีเนื้อเพลงแบบธรรมดา + + + %1$s ใน %2$s + %1$d kbps + %1$s kHz • %2$d ช่อง + ยังไม่ได้เลือกปลั๊กอินเมทาดาทา + + + ย้อนกลับ + ลองอีกครั้ง + ปิดกล่องโต้ตอบ + กำลังโหลด... + ไม่ทราบ + ข้อผิดพลาดที่ไม่ทราบสาเหตุ + + + ผู้ใช้ + เพลย์ลิสต์ + + + ขออภัยเป็นอย่างยิ่ง + มีบางอย่างทำงานไม่ถูกต้องในขณะนี้ ประสบการณ์ของคุณสำคัญสำหรับเรา และเรากำลังพยายามแก้ไขอย่างเต็มที่ โปรดลองอีกครั้งหรือตรวจสอบรายละเอียดข้อผิดพลาดด้านล่าง + รายละเอียดข้อผิดพลาด + คัดลอกไปยังคลิปบอร์ด + ดูบันทึกทั้งหมด + บันทึกข้อผิดพลาดทั้งหมด + + + สุ่มเพลง + เล่นแบบสุ่ม + ติดตาม + เลิกติดตาม + แก้ไขเพลย์ลิสต์ + โดย %1$s + + + ตัวเลือกเพลง + เพิ่มลงในแจม + เริ่มวิทยุ + เล่นถัดไป + ย้ายไปเป็นเพลงถัดไป + นำออกจากคิว + นำออกจากรายการโปรด + บันทึกเป็นรายการโปรด + เพิ่มลงในเพลย์ลิสต์ + ดาวน์โหลด + แชร์ + + + ลำดับเดิม + ชื่อเพลง + ศิลปิน + อัลบั้ม + ความยาว + เรียงลำดับ + ไม่พบเพลง + กรอง + การดำเนินการแบบกลุ่ม + ดาวน์โหลดทั้งหมด + ดาวน์โหลด %1$d เพลง + เพิ่มทั้งหมดลงในคิว + เพิ่ม %1$d เพลงลงในคิว + เล่นทั้งหมดถัดไป + เล่น %1$d เพลงถัดไป + เพิ่มทั้งหมดลงในเพลย์ลิสต์ + เพิ่ม %1$d เพลงลงในเพลย์ลิสต์ + เพิ่มทั้งหมดลงในแจม + เพิ่ม %1$d เพลงลงในแจม + +อีก %1$d + + + ไม่มี URL ให้โหลด + ย้อนกลับในประวัติเบราว์เซอร์ + ไปข้างหน้าในประวัติเบราว์เซอร์ + + + กรองบัญชีดำ... + ไม่มีรายการในบัญชีดำ + ไม่มีรายการในบัญชีดำที่ตรงกับ “%1$s” + เพลง + เพลง + เพิ่มลงในบัญชีดำ + นำออกจากบัญชีดำ + + + ไม่มีเมทาดาทาของเพลง + ไม่สามารถระบุ URL ของสตรีมได้ + ไม่สามารถระบุ URL ของสตรีมได้ ลองเล่นเพลงนี้ก่อน + + + เพลงที่บันทึกไว้ + คุณ + + + กำลังติดตาม + เพลงยอดนิยม + เล่นเพลงยอดนิยม + เพิ่มเพลงยอดนิยมลงในคิว + ศิลปินที่เกี่ยวข้อง + เพลย์ลิสต์แนะนำ + โหลดภาพรวมศิลปินไม่สำเร็จ + ไม่มีข้อมูลผู้ติดตาม + ผู้ติดตาม %1$sK คน + ผู้ติดตาม %1$sM คน + ผู้ติดตาม %1$sB คน + + ผู้ติดตาม %1$d คน + + + + ถูกใจ + เลิกถูกใจ + ชื่อเพลงตัวอย่าง + ศิลปินตัวอย่าง + ไม่ทราบอัลบั้ม + เลื่อนตำแหน่ง + ระดับเสียง + เปิดการสุ่มเพลง + ปิดการสุ่มเพลง + ก่อนหน้า + ถัดไป + เล่นหรือหยุดชั่วคราว + ปิดการเล่นซ้ำ + เล่นซ้ำเพลงเดียว + เล่นซ้ำทั้งหมด + ปิดเสียง + เปิดเสียง + ตัวเลือกเพิ่มเติม + ตัวเลือกเครื่องเล่น + ยุบเครื่องเล่น + ขยายเนื้อเพลง + กำลังเล่น + เนื้อเพลง + แหล่งที่มาสำรอง + แหล่งที่มาสำรอง + ดาวน์โหลด + ดาวน์โหลดแล้ว + กำลังดาวน์โหลด... + ดาวน์โหลดไว้แล้ว + กำลังลองดาวน์โหลดอีกครั้ง... + กำลังดาวน์โหลด %1$s + การเล่นจะหยุดชั่วคราวในอีก %1$s + หยุดการเล่นชั่วคราวหลังจากจำนวนนาทีที่เลือก + นาที + เลือกระหว่าง 1 ถึง 600 นาที + ยกเลิกตัวตั้งเวลา + เริ่ม + URL สำหรับเล่นถูกระบุด้วยการตั้งค่าการสตรีมที่ต่างออกไป สตรีมที่ไฮไลต์ตรงกับค่าที่คุณต้องการในปัจจุบัน URL ที่ใช้งานอยู่จะรีเฟรชเมื่อมีการขอสตรีมครั้งถัดไป + แหล่งเสียงที่จับคู่ได้ + กำลังโหลดแหล่งที่จับคู่ได้… + ไม่จำเป็นต้องใช้แหล่งระยะไกลที่จับคู่ได้ขณะเล่นไฟล์ที่แคชไว้ในเครื่อง + ไม่มีแหล่งที่จับคู่ได้ + ไม่มีแหล่งเสียงที่จับคู่ได้ถูกแคชไว้สำหรับเพลงนี้ + ยังไม่ได้แคชรายละเอียดสตรีม เล่นเพลงนี้ก่อนเพื่อโหลดข้อมูล + โหลดรายละเอียดเพลงไม่สำเร็จ + สตรีมที่มีให้ใช้ + เพลงนี้กำลังเล่นจากแคชในเครื่อง จึงไม่ได้ใช้สตรีมรูปแบบต่าง ๆ สำหรับการเล่นนี้ + กำลังโหลดสตรีม… + ไม่มีสตรีมสำหรับแหล่งนี้ + สตรีมที่เลือกอยู่ในปัจจุบัน + ตรงกับรูปแบบและคุณภาพการสตรีมที่คุณต้องการ + URL ที่ใช้เล่นอยู่ + เล่นจากแคชในเครื่อง + เพลงจากเมทาดาทา + ชื่อเพลง + ID + อัลบั้ม + ความแม่นยำ + URI ภายนอก + ID อัลบั้ม + ประเภทอัลบั้ม + วันที่วางจำหน่ายอัลบั้ม + จำนวนเพลงในอัลบั้ม + แนวเพลงของอัลบั้ม + ความยาว + หมายเลขเพลง + หมายเลขแผ่น + เนื้อหาไม่เหมาะสม + ความนิยม + ISRC + URL ภาพขนาดย่อ + + + สลับแถบด้านข้าง + + + แหล่งที่มาสำรอง + ไม่มีเพลงที่กำลังเล่นอยู่ + กำลังค้นหาแหล่งที่มาสำรอง... + ไม่พบแหล่งที่มาสำรอง + ความแม่นยำ: %1$d% + แหล่งที่ใช้งานอยู่ + + + เตะออก + นำผู้ใช้นี้ออกจากเซสชัน + เตะออกและไม่ให้กลับเข้ามาอีก + ลบเพลงที่เสนอ + ลบทุกเพลงที่ผู้ใช้นี้เพิ่มลงในคิว + จัดลำดับใหม่ + นำออกจากคิว + %1$s • เพิ่มโดย %2$s + + + ไม่ทราบ + กำลังโหลดเพลย์ลิสต์... + เพิ่มเพลง + เพิ่มเพลงลงในเพลย์ลิสต์ + ค้นหาเพลง... + ค้นหา + ไม่พบเพลง + ค้นหาเพลงที่จะเพิ่ม + เสร็จสิ้น + เพิ่ม + เพิ่มแล้ว + + + กำลังโหลดอัลบั้ม... + + + เพิ่มลงในเพลย์ลิสต์ของคุณ + ค้นหา + ค้นหาเพลย์ลิสต์ของคุณ... + กำลังโหลดเพลย์ลิสต์ของคุณ... + คุณยังไม่มีเพลย์ลิสต์ของตัวเอง\nสร้างเพลย์ลิสต์เพื่อเพิ่มเพลง + สร้างเพลย์ลิสต์ + แก้ไขเพลย์ลิสต์ + สร้าง + ชื่อ + ชื่อเพลย์ลิสต์ + คำอธิบาย + คำอธิบาย (ไม่บังคับ) + สาธารณะ + ทุกคนสามารถเห็นเพลย์ลิสต์นี้ + ทำร่วมกัน + ผู้อื่นสามารถเพิ่มเพลงลงในเพลย์ลิสต์นี้ได้ + ภาพปก + ปกเพลย์ลิสต์ + ไม่มีภาพ + เปลี่ยนภาพ + เลือกภาพ + + + ติดตั้ง + แทนที่ + ไม่พบ plugin.json ในไฟล์ zip + ไม่พบ manifest.zipline.json ในไฟล์ zip + รูปแบบ plugin.json ไม่ถูกต้อง: %1$s + อ่านปลั๊กอินไม่สำเร็จ: %1$s + ติดตั้งปลั๊กอินไม่สำเร็จ: %1$s + มี %1$s เวอร์ชันใหม่กว่า โปรดยืนยันการอัปเดตก่อนติดตั้ง + %1$s ติดตั้งอยู่แล้ว โปรดยืนยันการแทนที่ก่อนติดตั้ง + ปลั๊กอินในตัวรวมอยู่แล้วและไม่สามารถเพิ่มซ้ำได้ + ไม่สามารถลบปลั๊กอินในตัวได้ + %1$s ระบุ apiVersion ไม่ถูกต้อง (%2$s) ต้องเป็น semantic versioning ที่เข้ากันได้กับ %3$s + เวอร์ชัน API ปลั๊กอินของ Spotube %1$s ไม่ถูกต้อง + %1$s ใช้ API ปลั๊กอิน %2$s แต่ Spotube รองรับ API ปลั๊กอินแบบ breaking %3$s โปรดติดตั้งปลั๊กอินที่สร้างสำหรับ API เวอร์ชัน breaking เดียวกัน + + + API ปลั๊กอินเข้ากันไม่ได้ + ติดตั้งปลั๊กอินหรือไม่? + %1$s จะถูกเพิ่มลงในปลั๊กอินที่ติดตั้งไว้ของคุณ + อัปเดตปลั๊กอินหรือไม่? + มีการติดตั้งเวอร์ชันเก่ากว่าอยู่ (%1$s) อัปเดตเป็น %2$s หรือไม่? + แทนที่ปลั๊กอินที่ติดตั้งไว้หรือไม่? + %1$s ใช้ semantic version ที่ไม่ถูกต้อง (%2$s) จึงไม่สามารถเปรียบเทียบเป็นการอัปเดตได้ ต้องการแทนที่ปลั๊กอินที่ติดตั้งไว้ต่อไปหรือไม่? + ไม่สามารถเปรียบเทียบเวอร์ชันที่ติดตั้งไว้ (%1$s) ด้วย semantic versioning ได้ แทนที่ด้วย %2$s หรือไม่? + เวอร์ชัน %1$s ติดตั้งอยู่แล้ว แทนที่ปลั๊กอินเดิมด้วยสำเนาที่ให้มาหรือไม่? + เวอร์ชันที่ติดตั้งไว้ %1$s ใหม่กว่าเวอร์ชันที่ให้มา %2$s ต้องการแทนที่ต่อไปหรือไม่? + แทนที่ปลั๊กอินที่ติดตั้งไว้ด้วยสำเนาที่ให้มาหรือไม่? + + + %1$s: + เวอร์ชัน + เวอร์ชัน API + ผู้สร้าง + สัญญาอนุญาต + สิทธิ์การเข้าถึง + ความสามารถ + รีโพซิทอรี + ติดต่อ + รายงานข้อบกพร่อง + สนับสนุน %1$s + + + ทางการ + ยืนยันแล้ว + ดาวบน GitHub + URL รีโพซิทอรี GitHub + + + ไม่พบรีลีส + เลือกรีลีส + เลือกรีลีส + รุ่นทดสอบก่อนเปิดตัว + บันทึกประจำรุ่น + + + หน้าแรก + ค้นหา + ในอุปกรณ์ + เชื่อมต่อ + อุปกรณ์ + + + ซ่อนหน้าต่าง + แสดงหน้าต่าง + เพลงถัดไป + เพลงก่อนหน้า + สุ่มเพลง + เล่นซ้ำ + เล่นซ้ำ: ปิด + เล่นซ้ำ: เพลงเดียว + เล่นซ้ำ: ทั้งหมด + ระดับเสียง: %1$d% + เพิ่มเสียง + + ลดเสียง - + ปิดเสียง + เปิดเสียง + ถูกใจเพลง + เลิกถูกใจเพลง + ออก + + + ย่อขนาด + ขยายใหญ่สุด + คืนขนาด + ปิด + + + บริการเล่นเพลง + การเล่น + ตัวควบคุมการเล่นสื่อ + + + เรียกดู + กำลังเล่น + แนะนำ + เพลงที่บันทึกไว้ + เพลงยอดนิยม + diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml new file mode 100644 index 00000000..6f7bea1a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -0,0 +1,938 @@ + + + + Sıra + Sırayı temizle + Sırada filtrele... + Sıra boş + + Şarkı, sanatçı, albüm ara... + Tüm geçmişi temizle + Ara... + + + Kitaplığın + Kitaplık + Çalma listeleri + Albümler + Sanatçılar + Yerel parçalar + İndirilenler + Kayıtlı çalma listelerinde ara… + Kayıtlı albümlerde ara… + Kayıtlı sanatçılarda ara… + Klasör veya parça ara… + Kitaplığı filtrele + Filtreyi kapat + Çalma listesi oluştur + Yerel parçaları yenile + Izgara görünümüne geç + Liste görünümüne geç + Kayıtlı çalma listesi yok + “%1$s” ile eşleşen çalma listesi yok + Kayıtlı albüm yok + “%1$s” ile eşleşen albüm yok + Kayıtlı sanatçı yok + “%1$s” ile eşleşen sanatçı yok + + + Klasörler + Klasör + %1$s • %2$s + + %1$d parça + %1$d parça + + + %1$d klasör + %1$d klasör + + Klasörlere dön + Klasörü çal + Ses erişimi gerekli + Cihazındaki müziklere göz atmak için ses erişimine izin ver. + Erişim izni ver + Yerel klasör bulunamadı + Bu klasörde parça yok + Eşleşen klasör veya parça yok + Yerel medya yenilenemedi + + + %1$d etkin • %2$d tamamlandı + Tamamlananları temizle + Henüz indirme yok + İndiriliyor: %1$d% + Sırada + Tamamlandı + İptal edildi + Başarısız + Başarısız: %1$s + İndirmeyi iptal et + İndirmeyi yeniden dene + İndirmeyi kaldır + + + Sanatçı + Kara listede + Beğenilen Şarkılar + Kaydettiğin şarkılar + Bilinmeyen sanatçı + Parça adı + Sanatçı adı + + Seçili + + Ayarlar + + Eklentiler + Dil ve Bölge + Görünüm + Çalma + İndirmeler + Önbellek + Masaüstü + Güncellemeler + Destek + Hakkında + + Kapat + Kaydet + İptal + + Dil + Uygulama arayüzünün dili. Geçerli: %1$s + Sistem varsayılanı + Ülke + İçerik ve kullanılabilirlik için bölge ülkesi. Geçerli: %1$s + %1$s (%2$s) + + Tema + Spotube\'un sistem görünümüne nasıl uyacağını seç. Geçerli: %1$s + Açık + Koyu + Sistem + + Yüzey + Uygulamanın arka plan malzemesi. Geçerli: %1$s + Su + Buz + Krom + Akrilik + Cam + + Vurgu rengi + Geçerli palet: %1$s + Vurgu rengi + Bir vurgu paleti seç ve hem açık hem koyu temada önizle. + Açık + Koyu + Önizleme + Düğme + Yeşil Goblin + Elektrik Moru + Okyanus Camgöbeği + Gün Batımı Turuncusu + Gül Bahçesi + Gece Mavisi + Metalik Arduvaz + + Görünüm ve tarz + Yüzey malzemesi ve vurgu. Geçerli: %1$s · %2$s + Görünüm ve tarz + Önce bir yüzey malzemesi, ardından ona uyan bir vurgu seç. Her seçenek özenle hazırlanmış bir kombinasyon. + Yüzey + Vurgu + İkincil + + Palet + Millennium vurgu paleti. Geçerli: %1$s + Palet + Bir Millennium vurgu paleti seç ve açık ile koyu temada önizle. + Palet + Millennium paleti ve vurgu. Geçerli: %1$s + Mavi + Turuncu + Limon yeşili + + Özel indirme klasörü + Geçerli klasör: %1$s + Geçerli klasör: varsayılan müzik klasörü + Varsayılan müzik klasörünü kullanmak için boş bırak. + C:\Music\Spotube + Yerel medya klasörleri + Masaüstünde taranacak özel klasörler. Eklenen: %1$d + Henüz özel klasör eklenmedi + Yerel müzik kitaplığın için özel klasörler. + Yönet + Klasör ekle + Klasörü kaldır + Özel klasör eklenmedi. + %1$s klasörünü tarama listesinden kaldır + İndirme ve müzik önbelleği klasörleri otomatik olarak dahil edilir. + İndirme klasörün otomatik olarak dahil edilir. + Müzik önbelleği klasörü otomatik olarak dahil edilir. + Kaydediliyor… + Klasörler kaydedilemedi. Değişikliklerin duruyor; tekrar dene. + + %1$d klasör + %1$d klasör + + İndirme müzik biçimi + İndirmeler için tercih edilen kodek ve kapsayıcı. Geçerli: %1$s + İndirme müzik kalitesi + + Akış müzik biçimi + Müzik akışı için tercih edilen kodek ve kapsayıcı. Geçerli: %1$s + Akış müzik kalitesi + Geçerli: %1$s + Müzik önbelleğini etkinleştir + Tekrar çalmayı hızlandırmak için akışla gelen sesi cihazda sakla. + Önbellek klasörü + Geçerli: %1$s + Geçerli: varsayılan uygulama önbelleği + Önbellek boyutu sınırı + Sınır yok + Geçerli sınır: %1$d MB + Maksimum önbellek boyutunu MB cinsinden belirle. Sınır olmaması için 0 gir veya boş bırak. + 500 + Boyut 0 veya pozitif bir sayı olmalı. + Sonsuz çalmayı etkinleştir + Sıran bittiğinde önerilen parçalarla çalmaya devam et. + Connect\'i etkinleştir + Uzaktan çalma kontrollerini Spotube Connect özelliğiyle sun. + Çalma proxy sunucusu portu + Uzaktan kontrole izin ver + Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin. + Uzaktan kontrol cihaz adı + Diğer cihazların bu cihazı bulurken gördüğü ad. Geçerli: %1$s + Ağındaki diğer cihazlar bu Spotube\'u bulduğunda görünecek kolay bir ad seç. + örn. Salon Spotube + cihaz ana bilgisayar adı + Çalma proxy sunucusunun kullandığı port. Geçerli: %1$d + 1 ile 65535 arasında bir port seç. + 14769 + Geçerli bir tam sayı gir. + Port 1 ile 65535 arasında olmalı. + + Kara liste + Kara listedeki parçaları ve sanatçıları yönet + + Sistem tepsisine küçült + Pencere küçültüldüğünde Spotube sistem tepsisinde çalışmaya devam etsin. + Discord Rich Presence + Masaüstünde şu an çalanı Discord\'da göster. + + Güncellemeleri otomatik denetle + Yeni Spotube sürümlerini arka planda ara. + + Eklentileri Yönet + Eklentilerini yönet + Varsayılan %1$s eklentisi + Meta veri + Ses + Şarkı sözleri + Scrobble + Eklenti seçilmedi + Değiştir + Seç + Seçili + Seçimi temizle + Kullanılabilir eklenti yok + %1$s eklentisi + Temizle + + Yapılandır + Eklenti Yöneticisi + Lütfen bir URL gir + URL, http:// veya https:// ile başlamalı + Eklenti indirilemedi + Henüz yüklü eklenti yok + Bir indirme URL\'si yapıştır veya yukarıdan bir dosya seç. + + %1$d eklenti yüklü + %1$d eklenti yüklü + + Eklenti Yükle + https://example.com/plugin.smplug + İndir + Dosyadan yükle (.smplug) + Etkin + v%1$s + Yerleşik + Eklentiyi kaldır + Giriş yap + Çıkış yap + URL\'den indir + Dosyadan yükle + Yükle + + • %1$s + İstenen izinler + Bu eklenti özel bir izin gerektirmiyor. + Yüklü ve sağlanan + Yüklü + Sağlanan + API %1$s → %2$s + Kalıcı Depolama + Diske veri okuma ve yazma + Ağ İstekleri + İnternet üzerinden veri gönderme ve alma + WebView + Uygulama içinde web içeriği görüntüleme + Grup Jam + MQTT Aracısı + Aracı ana bilgisayarı + %1$s:%2$d + Aracı portu + TLS kullan + Kullanıcı adı (isteğe bağlı) + Şifre (isteğe bağlı) + İstemci kimliği ön eki + Bu Spotube örneğini tanımlamak için kullanılır + Aracı bağlantısını şifrele + Oda başlatmadan önce geçerli aracı ayarlarını kontrol et. + Bağlantıyı test et + Test ediliyor… + %1$d ms\'de bağlandı + Başarısız: %1$s + Geçici aracı — kendin barındırmak için kendi aracını yapılandır + Bağlantı + Kimlik bilgileri + Kimlik + Tanılama + + + Grup Jam + Oluştur + Katıl + Bir oda oluştur, sonra kodunu arkadaşlarınla paylaş. + Oda oluştur + Odaya katıl + Bağlanıyor… + Oda kodu + ABC123 + Aracı: %1$s + Oda açmak veya bir odaya katılmak için Ayarlar\'da bir aracı ayarla. + Aracı ayarları + Bağlandı + Yeniden bağlanıyor… + Kodu kopyala + Kopyalandı! + Paylaş + Katılımcılar (%1$d) + Sahip + Dinleyici + Dinleyiciler bekleniyor… + Çıkar + Engelle + Yönet + Odadan ayrıl + Bağlanılamadı + Kapat + Jam oturumu oluşturulamadı + Jam oturumuna katılınamadı + Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir. + Spotube Jam\'ime şu kodla katıl: %1$s + + Albüme git + Uyku zamanlayıcısı + Parça ayrıntıları + Çal + Duraklat + Hiçbir şey çalmıyor + Sıraya ekle + + Yok say + Güncelle + Spotube %1$s kullanıma sunuldu + Bu sürümdeki yenilikleri Spotube indirme sayfasında gör. + %1$s %2$s kullanıma sunuldu + Şu an yüklü: %1$s + Bu güncellemedeki yenilikleri eklentinin sayfasında gör. + + Atla + İleri + Geri + Başla + Spotube + Müzik, hiçbir kısıtlama olmadan. + Koca bir müzik dünyası için açık kaynaklı bir oynatıcı.\nKeşfetmesi sana kalmış. Kendine göre şekillendirmesi de. + Bu projeyi yaşat. + Spotube\'u bir abonelik şirketi değil, insanlar geliştiriyor. Günlük hayatının bir parçası olduysa, küçük bir destek bile ona hak ettiği özeni göstermemize yardımcı olur.\n\nBağımsız yazılıma yer açtığın için teşekkürler. + Şarkı ve çalma listesi bulmak için bir katalog eklentisiyle başla. Onları çalmak için bir ses eklentisi seç. Şarkı sözleri ve dinleme geçmişi isteğe bağlı. Aşağıdan bir eklenti keşfet, izinlerini incele, yükle ve burada seç. + Eklentileri keşfet + Kaynakların + Tekrar dene + Daha fazla eklenti + Yeni eklenti bulunamadı. Yukarıdan bir bağlantı veya .smplug dosyasıyla da yükleyebilirsin. + Eklenti keşfi şu anda kullanılamıyor. Tekrar dene ya da bir bağlantı veya dosyadan yükle. + Hoş geldin + Destek + Eklentiler + Rehber + + Spotube nasıl kullanılır + Dört hızlı adım ve dinlemeye başlıyorsun. + Giriş yap + Spotify hesabını bağla veya kendi katalog eklentini seç. + Ara ve çal + Herhangi bir şarkıyı, albümü veya sanatçıyı bul, sonra çal\'a bas. + Çevrimdışı dinle + Bağlantı olmadan dinlemeye devam etmek için parçaları indir. + Sözleri takip et + Bir parça çalarken oynatıcıdan senkronize sözleri aç. + + + Senin müziğin. Senin kaynakların. Senin kuralların. + Müziği sadece seven bir topluluğun geliştirdiği ücretsiz, açık kaynaklı bir müzik oynatıcı. + Açık kaynak + Reklamsız + Telemetri yok + Eklenti destekli + Hadi Başlayalım + + + Destekçilerimiz bizi ayakta tutuyor + Tutkuyla yapıldı. Seninle yaşıyor. + Spotube %100 ücretsiz, özgür ve açık kaynak — reklam yok, abonelik yok, takip yok. Her özellik, kodun akmaya devam etmesine yardım etmeyi seçen senin gibi insanlar sayesinde var. + Patreon + Aylık Sponsor + Sürekli geliştirmeyi ve altyapıyı destekle + Buy Me a Coffee + Tek Seferlik Kahve + Küçük bir bahşişle gece yarısı kodlama seansına güç ver + Open Collective + Topluluk Fonu + Şeffaf, topluluk tarafından yönetilen finansal destek + Açık müziğe inandığın için tüm kalbimizle teşekkür ederiz. + Destek tamamen isteğe bağlı — Spotube her durumda ücretsiz kalır. + Sponsor ol + %1$s sayfasını aç + + + Spotube\'u destekle + Spotube\'u destekle + Spotube ücretsiz, özgür ve açık kaynak — reklam yok, abonelik yok, takip yok. Sana keyif veriyorsa projeyi desteklemeyi düşünebilirsin. + Bu hatırlatıcıları Ayarlar → Destek bölümünden kapatabilirsin. + Belki sonra + Destek hatırlatıcıları + Projeyi desteklemek için her 7 günde bir nazik bir hatırlatma göster. + + + Modüler Mimari + Müziğinin nereden geleceğini seç. + Spotube katalog keşfini, ses akışını ve senkronize sözleri birbirinden ayırır. Motorlarını aşağıdan yapılandır veya istediğin zaman değiştir. + Meta Veri Motoru + Müzik keşfini, aramayı, parça listelerini ve çalma listelerini sağlar. + Ses Motoru + Çalınan ses akışlarını getirir ve çözer. + Şarkı Sözü Motoru + Zamanla senkronize karaoke sözlerini gerçek zamanlı olarak sunar. + Scrobble Motoru + Dinleme geçmişini Last.fm veya ListenBrainz\'e kaydeder. + Etkin + Yerleşik + Hazır + İsteğe bağlı + Bir motor seç + Yüklü Motorlar + Varsayılan yap + NewPipe YouTube ve LRCLib Lyrics yerleşiktir ve kutudan çıktığı gibi akışa hazırdır. + Topluluk eklentilerini istediğin zaman Ayarlar → Eklentiler bölümünden yönetebilir veya yenilerini ekleyebilirsin. + + + Retro-Modern Mimari + Saf Hi-Fi, sıfır aracı + Spotube\'un merkeziyetsiz çalmayı doğrudan cihazında nasıl yönettiğini gör. + DOĞRUDAN EŞLER ARASI • 256 KBPS OPUS + Mavi saat + Spotube · oynatıcı tanıtımı + Aradaki anlar için biraz müzik. + 1. Merkeziyetsiz Keşif + Sorgular doğrudan seçtiğin katalog eklentisine gider. Spotube zevkinin profilini asla çıkarmaz. + 2. Doğrudan Akış Çözümleme + Ses, içerik sunucularından doğrudan cihazına akar. Proxy aktarması veya uzaktan veri toplama yok. + 3. Yerel DSP ve Çalma + Ekolayzer kontrolleri ve isteğe bağlı çevrimdışı indirme ile cihazında çözülür ve önbelleğe alınır. + 4. Senkronize Sözler ve Jam + Karaoke sözleri çalma zamanını takip eder; LAN üzerinden uzaktan kontrol ve Grup Jam de var. + Aracı sunucu yok. Çalma doğrudan cihazında gerçekleşir — tasarımı gereği gizli. + + + Bir cihaz seç + %1$s: “%2$s” + Çal + Sıraya ekle + Sonra çal + Bu Cihaz + Bu cihazda + Uzak Cihaz + Uzak Cihaz (%1$s) + Bağlı cihazda + + + Bağlantı başarısız + Bağlantı kesildi + Gönderilemedi + Keşif başarısız: %1$s + Bağlanılamadı: %1$s + + + Cihazlar + Yenile + %1$s:%2$d adresine bağlı + Bağlantıyı kes + Bağlanıyor... + Ağda cihaz aranıyor... + Cihaz bulunamadı + Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol. + Bilinmeyen Cihaz + Çözümleniyor... + Kimlik: %1$s... + + + Uzaktan Kontrol + Bağlantı kesildi + Bağlantı hatası: %1$s + Albüm kapağı + Bilinmeyen Parça + Bilinmeyen Sanatçı + Konum + Karıştır + Önceki + Sonraki + Tekrar modu + Ses düzeyi + Sıradan kaldır + Diğer seçenekler + + + Nerede Çalınsın? + Etkin bir Jam oturumun var. Bu koleksiyonun nerede çalınacağını seç. + Burada çal + Jam\'e öner + + + Jam aracısı yapılandırılmadı + Aracı ana bilgisayarı boş + Bağlantı başarısız + Bağlantı kesildi + + + Aracı + anonymous + + + Jam sırasına eklendi + + %1$d parça Jam sırasına eklendi + %1$d parça Jam sırasına eklendi + + + + Uzaktan Kontrol İsteği + \"%1$s\" bu cihazda çalmayı kontrol etmek istiyor. + Reddet + İzin ver + Her Zaman İzin Ver + + + Arama sonuçları + Tümü + Parçalar + Kullanıcılar + Kullanılabilir arama türü yok + Aramak için yazmaya başla + Parça aramak için yazmaya başla + Sonuç bulunamadı + Tümünü gör + Aranıyor + Parçalar yükleniyor... + Albümler yükleniyor... + Sanatçılar yükleniyor... + Çalma listeleri yükleniyor... + Kullanıcılar yükleniyor... + Albüm bulunamadı + Sanatçı bulunamadı + Çalma listesi bulunamadı + Kullanıcı bulunamadı + Plak kasaları karıştırılıyor... + Evren taranıyor... + Hoparlörler ısınıyor... + Plakların tozu alınıyor... + Müzik tanrılarına danışılıyor... + Antenler ayarlanıyor... + Plaklar karıştırılıyor... + Arama özellikleri yüklenemedi + Arama yapılamadı + Parçalar aranamadı + Albümler aranamadı + Sanatçılar aranamadı + Çalma listeleri aranamadı + Kullanıcılar aranamadı + + + Göz at + + + Senkronize + Düz + Senkronize şarkı sözü yok + Düz şarkı sözü yok + + + %2$s içinde %1$s + %1$d kbps + %1$s kHz • %2$d kanal + Meta veri eklentisi seçilmedi + + + Geri + Tekrar dene + İletişim kutusunu kapat + Yükleniyor... + Bilinmiyor + Bilinmeyen hata + + + Kullanıcı + Çalma listesi + + + Gerçekten Üzgünüz + Şu anda bir şeyler düzgün çalışmıyor. Deneyimin bizim için önemli ve bunu düzeltmek için elimizden geleni yapıyoruz. Lütfen tekrar dene veya aşağıdaki hata ayrıntılarına göz at. + Hata Ayrıntıları + Panoya kopyala + Tam günlüğü görüntüle + Tam Hata Günlüğü + + + Karıştır + Karışık çal + Takip et + Takibi bırak + Çalma listesini düzenle + %1$s tarafından + + + Parça seçenekleri + Jam\'e ekle + Radyo başlat + Sonra çal + Sıradakine taşı + Sıradan kaldır + Favorilerden kaldır + Favorilere kaydet + Çalma listesine ekle + İndir + Paylaş + + + Orijinal + Başlık + Sanatçı + Albüm + Süre + Sırala + Parça bulunamadı + Filtrele + Toplu işlemler + Tümünü İndir + %1$d Parçayı İndir + Tümünü Sıraya Ekle + %1$d Parçayı Sıraya Ekle + Tümünü Sonra Çal + %1$d Parçayı Sonra Çal + Tümünü Çalma Listesine Ekle + %1$d Parçayı Çalma Listesine Ekle + Tümünü Jam\'e Ekle + %1$d Parçayı Jam\'e Ekle + +%1$d kişi daha + + + Yüklenecek URL yok + Tarayıcı geçmişinde geri git + Tarayıcı geçmişinde ileri git + + + Kara listeyi filtrele... + Kara listede öğe yok + Kara listede \'%1$s\' ile eşleşen öğe yok + Parçalar + Parça + Kara listeye ekle + Kara listeden kaldır + + + Parça meta verisi yok + Akış URL\'si çözümlenemedi + Akış URL\'si çözümlenemedi. Önce parçayı çalmayı dene. + + + Kayıtlı Parçalar + Sen + + + Takip ediliyor + Popüler Parçalar + Popüler parçaları çal + Popüler parçaları sıraya ekle + Benzer Sanatçılar + Öne Çıkan Çalma Listeleri + Sanatçı sayfası yüklenemedi + Takipçi sayısı kullanılamıyor + %1$s B takipçi + %1$s Mn takipçi + %1$s Mr takipçi + + %1$d takipçi + %1$d takipçi + + + + Beğen + Beğenmekten vazgeç + Yer tutucu parça adı + Yer tutucu sanatçılar + Bilinmeyen Albüm + Konum + Ses düzeyi + Karıştırmayı aç + Karıştırmayı kapat + Önceki + Sonraki + Çal veya duraklat + Tekrar kapalı + Bir parçayı tekrarla + Tümünü tekrarla + Sesi kapat + Sesi aç + Diğer seçenekler + Oynatıcı seçenekleri + Oynatıcıyı daralt + Sözleri genişlet + Şu An Çalıyor + Şarkı Sözleri + Alternatif Kaynak + Alternatif kaynak + İndir + İndirildi + İndiriliyor... + Zaten indirildi + İndirme yeniden deneniyor... + %1$s indiriliyor + Çalma %1$s sonra duraklatılacak. + Seçilen dakika sayısından sonra çalmayı duraklat. + Dakika + 1 ile 600 dakika arasında seç + Zamanlayıcıyı iptal et + Başlat + Çalma URL\'si farklı akış ayarlarıyla çözümlendi. Vurgulanan akış geçerli tercihinle eşleşiyor; etkin URL bir sonraki akış isteğinde yenilenir. + Eşleşen ses kaynağı + Eşleşen kaynak yükleniyor… + Önbellekteki yerel dosya çalınırken eşleşen bir uzak kaynağa gerek yok. + Eşleşen kaynak yok. + Bu parça için önbellekte eşleşen ses kaynağı yok. + Akış ayrıntıları henüz önbelleğe alınmadı. Yüklemek için önce parçayı çal. + Parça ayrıntıları yüklenemedi. + Kullanılabilir akışlar + Bu parça yerel önbellekten çalınıyor. Bu çalma için akış varyantları kullanılmıyor. + Akışlar yükleniyor… + Bu kaynak için kullanılabilir akış yok. + Şu an seçili akış + Akış biçimi ve kalite tercihinle eşleşiyor + Etkin çalma URL\'si + Yerel önbellekten + Meta veri parçası + Başlık + Kimlik + Albüm + Eşleşme güveni + Harici URI + Albüm kimliği + Albüm türü + Albüm çıkış tarihi + Albümdeki parça sayısı + Albüm türleri + Süre + Parça numarası + Disk numarası + Müstehcen + Popülerlik + ISRC + Küçük resim URL\'leri + + + Kenar çubuğunu aç/kapat + + + Alternatif Kaynaklar + Şu anda çalan parça yok + Alternatif kaynaklar aranıyor... + Alternatif kaynak bulunamadı + Eşleşme: %1$d% + Etkin kaynak + + + At + Oturumdan çıkar + At ve yeniden katılmasını engelle + Önerileri kaldır + Sıraya eklediği tüm parçaları kaldır + Yeniden sırala + Sıradan kaldır + %1$s • Ekleyen: %2$s + + + Bilinmiyor + Çalma listesi yükleniyor... + Parça Ekle + Çalma Listesine Parça Ekle + Parça ara... + Ara + Parça bulunamadı + Eklemek için parça ara + Bitti + Ekle + Eklendi + + + Albüm yükleniyor... + + + Çalma Listene Ekle + Ara + Çalma listelerinde ara... + Çalma listelerin yükleniyor... + Henüz sana ait bir çalma listesi yok.\nParça eklemek için bir tane oluştur. + Çalma Listesi Oluştur + Çalma Listesini Düzenle + Oluştur + Ad + Çalma listesi adı + Açıklama + Açıklama (isteğe bağlı) + Herkese açık + Bu çalma listesini herkes görebilir + Ortak + Başkaları bu çalma listesine parça ekleyebilir + Kapak Görseli + Çalma listesi kapağı + Görsel yok + Görseli Değiştir + Görsel Seç + + + Yükle + Değiştir + Zip dosyasında plugin.json bulunamadı + Zip dosyasında manifest.zipline.json bulunamadı + Geçersiz plugin.json biçimi: %1$s + Eklenti okunamadı: %1$s + Eklenti yüklenemedi: %1$s + %1$s için daha yeni bir sürüm var. Yüklemeden önce güncellemeyi onayla. + %1$s zaten yüklü. Yüklemeden önce değiştirmeyi onayla. + Yerleşik eklentiler zaten dahil ve tekrar eklenemez. + Yerleşik eklentiler kaldırılamaz. + %1$s geçersiz bir apiVersion (%2$s) bildiriyor. %3$s ile uyumlu anlamsal sürümleme bekleniyor. + Spotube eklenti API sürümü %1$s geçersiz. + %1$s, eklenti API\'si %2$s hedefliyor ancak Spotube uyumsuz eklenti API\'si %3$s destekliyor. Aynı ana API sürümü için derlenmiş bir eklenti yükle. + + + Eklenti API\'si uyumlu değil + Eklenti yüklensin mi? + %1$s yüklü eklentilerine eklenecek. + Eklenti güncellensin mi? + Daha eski bir sürüm yüklü (%1$s). %2$s sürümüne güncellensin mi? + Yüklü eklenti değiştirilsin mi? + %1$s geçersiz bir anlamsal sürüm (%2$s) kullanıyor ve güncelleme olarak karşılaştırılamıyor. Yüklü eklenti yine de değiştirilsin mi? + Yüklü sürüm (%1$s) anlamsal sürümlemeyle karşılaştırılamıyor. %2$s ile değiştirilsin mi? + %1$s sürümü zaten yüklü. Mevcut eklenti sağlanan kopyayla değiştirilsin mi? + Yüklü %1$s sürümü, sağlanan %2$s sürümünden daha yeni. Yine de değiştirilsin mi? + Yüklü eklenti sağlanan kopyayla değiştirilsin mi? + + + %1$s: + Sürüm + API Sürümü + Geliştirici + Lisans + Yetenekler + İşlevler + Depo + İletişim + Hata Bildir + %1$s eklentisini destekle + + + Resmi + Doğrulanmış + GitHub yıldızları + GitHub depo URL\'si + + + Sürüm bulunamadı + Sürüm Seç + Bir sürüm seç + Ön sürüm + Sürüm Notları + + + Ana sayfa + Ara + Cihazda + Bağlan + Cihazlar + + + Pencereyi Gizle + Pencereyi Göster + Sonraki Parça + Önceki Parça + Karıştır + Tekrarla + Tekrar: KAPALI + Tekrar: BİR + Tekrar: TÜMÜ + Ses düzeyi: %1$d% + Ses + + Ses - + Sesi Kapat + Sesi Aç + Parçayı Beğen + Beğenmekten Vazgeç + Çıkış + + + Simge durumuna küçült + Ekranı kapla + Önceki boyuta getir + Kapat + + + Çalma hizmeti + Çalma + Medya çalma kontrolleri + + + Göz at + Şu An Çalıyor + Öne Çıkanlar + Kayıtlı Parçalar + Popüler Parçalar + diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml new file mode 100644 index 00000000..37273b97 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -0,0 +1,950 @@ + + + + Черга + Очистити чергу + Фільтр черги... + Черга порожня + + Треки, виконавці, альбоми... + Очистити історію + Пошук... + + + Моя бібліотека + Бібліотека + Плейлисти + Альбоми + Виконавці + Локальні треки + Завантаження + Пошук у збережених плейлистах… + Пошук у збережених альбомах… + Пошук серед збережених виконавців… + Пошук папок або треків… + Фільтр бібліотеки + Закрити фільтр + Створити плейлист + Оновити локальні треки + Показати сіткою + Показати списком + Немає збережених плейлистів + Немає плейлистів за запитом «%1$s» + Немає збережених альбомів + Немає альбомів за запитом «%1$s» + Немає збережених виконавців + Немає виконавців за запитом «%1$s» + + + Папки + Папка + %1$s • %2$s + + %1$d трек + %1$d треки + %1$d треків + %1$d трека + + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Назад до папок + Слухати папку + Потрібен доступ до аудіо + Дозвольте доступ до аудіо, щоб переглядати музику на пристрої. + Надати доступ + Локальних папок не знайдено + У цій папці немає треків + Немає відповідних папок або треків + Не вдалося оновити локальні файли + + + Активних: %1$d • Завершених: %2$d + Очистити завершені + Завантажень поки немає + Завантаження %1$d% + У черзі + Завершено + Скасовано + Помилка + Помилка: %1$s + Скасувати завантаження + Повторити завантаження + Видалити завантаження + + + Виконавець + У чорному списку + Улюблені треки + Ваші збережені треки + Невідомий виконавець + Назва треку + Ім’я виконавця + + Вибрано + + Налаштування + + Плагіни + Мова та регіон + Оформлення + Відтворення + Завантаження + Кешування + Комп’ютер + Оновлення + Підтримка + Про застосунок + + Закрити + Зберегти + Скасувати + + Мова + Мова інтерфейсу застосунку. Зараз: %1$s + Як у системі + Країна + Країна для контенту та доступності. Зараз: %1$s + %1$s (%2$s) + + Тема + Як Spotube підлаштовується під тему системи. Зараз: %1$s + Світла + Темна + Системна + + Поверхня + Фоновий матеріал застосунку. Зараз: %1$s + Вода + Лід + Хром + Акрил + Скло + + Акцентний колір + Поточна палітра: %1$s + Акцентний колір + Виберіть акцентну палітру й подивіться, як вона виглядає у світлій і темній темах. + Світла + Темна + Попередній перегляд + Кнопка + Зелений гоблін + Електрик-фіолетовий + Океанський ціан + Помаранчевий захід + Трояндовий сад + Опівнічний синій + Металік-графіт + + Зовнішній вигляд + Матеріал поверхні й акцент. Зараз: %1$s · %2$s + Зовнішній вигляд + Виберіть матеріал поверхні, а потім відповідний акцент. Кожен варіант — продумане поєднання. + Поверхня + Акцент + Другорядна + + Палітра + Акцентна палітра Millennium. Зараз: %1$s + Палітра + Виберіть акцентну палітру Millennium і перегляньте її у світлій і темній темах. + Палітра + Палітра Millennium і акцент. Зараз: %1$s + Синя + Помаранчева + Лаймова + + Власна папка для завантажень + Поточна папка: %1$s + Поточна папка: стандартна папка «Музика» + Залиште порожнім, щоб використовувати стандартну папку «Музика». + C:\Music\Spotube + Папки з локальною музикою + Додаткові папки для сканування на комп’ютері. Додано: %1$d + Додаткових папок ще не додано + Додаткові папки для вашої локальної бібліотеки. + Керувати + Додати папку + Видалити папку + Додаткових папок не додано. + Прибрати %1$s зі списку сканування + Папки завантажень і музичного кешу додаються автоматично. + Папка завантажень додається автоматично. + Папка музичного кешу додається автоматично. + Збереження… + Не вдалося зберегти папки. Зміни не втрачено — спробуйте ще раз. + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Формат завантаження музики + Бажаний кодек і контейнер для завантажень. Зараз: %1$s + Якість завантаження музики + + Формат стримінгу музики + Бажаний кодек і контейнер для стримінгу. Зараз: %1$s + Якість стримінгу музики + Зараз: %1$s + Кешувати музику + Зберігати прослухане аудіо на пристрої для швидшого повторного відтворення. + Папка кешу + Зараз: %1$s + Зараз: стандартний кеш застосунку + Ліміт розміру кешу + Без обмежень + Поточний ліміт: %1$d MB + Укажіть максимальний розмір кешу в MB. Введіть 0 або залиште порожнім, щоб зняти обмеження. + 500 + Розмір має бути 0 або додатним числом. + Безкінечне відтворення + Продовжувати відтворення рекомендованими треками, коли черга закінчиться. + Увімкнути Connect + Надавати віддалене керування відтворенням через Spotube Connect. + Порт проксі-сервера відтворення + Дозволити віддалене керування + Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення. + Назва пристрою для віддаленого керування + Назва, яку бачать інші пристрої під час пошуку. Зараз: %1$s + Задайте зрозумілу назву, яку бачитимуть інші пристрої у вашій мережі, коли знайдуть цей Spotube. + наприклад, Spotube у вітальні + ім’я хоста пристрою + Порт проксі-сервера відтворення. Зараз: %1$d + Укажіть порт від 1 до 65535. + 14769 + Введіть ціле число. + Порт має бути від 1 до 65535. + + Чорний список + Керування заблокованими треками й виконавцями + + Згортати в трей + Залишати Spotube в системному треї, коли вікно згорнуто. + Статус у Discord + Показувати в Discord, що зараз грає (на комп’ютері). + + Автоматично перевіряти оновлення + Шукати нові версії Spotube у фоновому режимі. + + Керування плагінами + Керуйте своїми плагінами + Плагін за замовчуванням: %1$s + Метадані + Аудіо + Тексти пісень + Скроблінг + Плагін не вибрано + Змінити + Вибрати + Вибрано + Скинути вибір + Немає доступних плагінів + Плагін %1$s + Скинути + + Налаштувати + Менеджер плагінів + Введіть URL + URL має починатися з http:// або https:// + Не вдалося завантажити плагін + Плагіни ще не встановлено + Вставте посилання для завантаження або виберіть файл вище. + + %1$d плагін встановлено + %1$d плагіни встановлено + %1$d плагінів встановлено + %1$d плагіна встановлено + + Встановлення плагіна + https://example.com/plugin.smplug + Завантажити + Встановити з файлу (.smplug) + Активний + v%1$s + Вбудований + Видалити плагін + Увійти + Вийти + Завантажити за URL + Встановити з файлу + Встановити + + • %1$s + Запитувані дозволи + Цьому плагіну не потрібні спеціальні дозволи. + Встановлений і новий + Встановлений + Новий + API %1$s → %2$s + Постійне сховище + Читання й запис даних на диск + Мережеві запити + Надсилання й отримання даних через інтернет + WebView + Показ вебвмісту всередині застосунку + Груповий джем + MQTT-брокер + Хост брокера + %1$s:%2$d + Порт брокера + Використовувати TLS + Ім’я користувача (необов’язково) + Пароль (необов’язково) + Префікс ID клієнта + Використовується для ідентифікації цього екземпляра Spotube + Шифрувати підключення до брокера + Перевірте поточні налаштування брокера перед створенням кімнати. + Перевірити підключення + Перевірка… + Підключено за %1$d мс + Помилка: %1$s + Тимчасовий брокер — налаштуйте власний, щоб розміщувати джеми самостійно + Підключення + Облікові дані + Ідентифікація + Діагностика + + + Груповий джем + Створити + Приєднатися + Створіть кімнату й поділіться її кодом із друзями. + Створити кімнату + Увійти в кімнату + Підключення… + Код кімнати + ABC123 + Брокер: %1$s + Налаштуйте брокер у налаштуваннях, щоб створити кімнату або приєднатися до неї. + Налаштування брокера + Підключено + Повторне підключення… + Копіювати код + Скопійовано! + Поділитися + Учасники (%1$d) + Організатор + Слухач + Чекаємо на слухачів… + Видалити + Заблокувати + Керувати + Вийти з кімнати + Не вдалося підключитися + Закрити + Не вдалося створити джем + Не вдалося приєднатися до джему + Введіть код кімнати з шести літер і цифр. + Приєднуйся до мого джему в Spotube за кодом: %1$s + + Перейти до альбому + Таймер сну + Відомості про трек + Відтворити + Пауза + Зараз нічого не грає + Додати до черги + + Пропустити + Оновити + Доступний Spotube %1$s + Дізнайтеся, що нового в цій версії, на сторінці завантажень Spotube. + Доступний %1$s %2$s + Встановлена версія: %1$s + Дізнайтеся, що нового в цьому оновленні, на сторінці плагіна. + + Пропустити + Далі + Назад + Почати + Spotube + Музика без зайвих умов. + Плеєр з відкритим кодом для цілого світу музики.\nДосліджуйте його. Зробіть його своїм. + Допоможіть проєкту жити. + Spotube створюють люди, а не бізнес на підписках. Якщо він став частиною вашого життя, навіть невелика підтримка допоможе нам приділяти йому ту увагу, на яку він заслуговує.\n\nДякуємо, що знаходите місце для незалежного софту. + Почніть із плагіна каталогу, щоб знаходити треки й плейлисти. Виберіть аудіоплагін для їх відтворення. Тексти пісень та історія прослуховувань — за бажанням. Знайдіть плагін нижче, перегляньте його дозволи, встановіть і виберіть тут. + Огляд плагінів + Ваші джерела + Повторити + Інші плагіни + Нових плагінів не знайдено. Плагін також можна встановити за посиланням або з файлу .smplug вище. + Каталог плагінів зараз недоступний. Повторіть спробу або встановіть плагін за посиланням чи з файлу. + Вітаємо + Підтримка + Плагіни + Посібник + + Як користуватися Spotube + Чотири прості кроки — і музика вже грає. + Вхід + Підключіть акаунт Spotify або виберіть власний плагін каталогу. + Шукайте й слухайте + Знайдіть будь-який трек, альбом чи виконавця й натисніть «Відтворити». + Слухайте офлайн + Завантажуйте треки, щоб слухати їх без інтернету. + Стежте за текстом + Відкривайте синхронізований текст у плеєрі під час відтворення. + + + Ваша музика. Ваші джерела. Ваші правила. + Безкоштовний музичний плеєр з відкритим кодом від спільноти, яка просто любить музику. + Відкритий код + Без реклами + Без телеметрії + На плагінах + Поїхали + + + Нас підтримують слухачі + Створено з любов’ю. Живе завдяки вам. + Spotube на 100% безкоштовний, вільний і відкритий — без реклами, підписок і стеження. Кожна функція існує завдяки таким людям, як ви, які допомагають проєкту розвиватися. + Patreon + Щомісячна підтримка + Підтримайте постійну розробку та інфраструктуру + Buy Me a Coffee + Разова кава + Підбадьорте нічну сесію програмування невеликим донатом + Open Collective + Фонд спільноти + Прозоре фінансування під керуванням спільноти + Щиро дякуємо, що вірите у відкриту музику. + Підтримка повністю добровільна — Spotube у будь-якому разі залишиться безкоштовним. + Підтримати + Відкрити %1$s + + + Підтримати Spotube + Підтримайте Spotube + Spotube безкоштовний, вільний і відкритий — без реклами, підписок і стеження. Якщо він приносить вам радість, підтримайте проєкт. + Ці нагадування можна вимкнути в розділі «Налаштування → Підтримка». + Можливо, пізніше + Нагадування про підтримку + Раз на 7 днів ненав’язливо нагадувати про підтримку проєкту. + + + Модульна архітектура + Виберіть, звідки брати музику. + У Spotube каталог, стримінг аудіо й синхронізовані тексти працюють окремо. Налаштуйте рушії нижче або змінюйте їх будь-коли. + Рушій метаданих + Відповідає за пошук музики, списки треків і плейлисти. + Аудіорушій + Отримує й декодує аудіопотоки для відтворення. + Рушій текстів + Показує синхронізовані караоке-тексти в реальному часі. + Рушій скроблінгу + Записує історію прослуховувань у Last.fm або ListenBrainz. + Активний + Вбудований + Готовий + Необов’язково + Виберіть рушій + Встановлені рушії + Зробити основним + NewPipe YouTube і LRCLib Lyrics вбудовані й працюють одразу після встановлення. + Керувати плагінами спільноти й додавати нові можна будь-коли в розділі «Налаштування → Плагіни». + + + Ретромодерн-архітектура + Чистий Hi-Fi без посередників + Ось як Spotube організовує децентралізоване відтворення просто на вашому пристрої. + НАПРЯМУ, P2P • 256 KBPS OPUS + Синя година + Spotube · демонстрація плеєра + Трохи музики для миттєвостей поміж справ. + 1. Децентралізований пошук + Запити надходять безпосередньо до вибраного плагіна каталогу. Spotube ніколи не аналізує ваші смаки. + 2. Пряме отримання потоку + Аудіо надходить безпосередньо із серверів контенту на ваш пристрій. Жодних проксі та віддаленого збору даних. + 3. Локальна обробка й відтворення + Декодування й кешування на пристрої, еквалайзер і завантаження для офлайн-прослуховування за бажанням. + 4. Синхронні тексти й джем + Караоке-тексти стежать за відтворенням, а ще є віддалене керування через LAN і груповий джем. + Жодних серверів-посередників. Відтворення відбувається просто на вашому пристрої — приватність за замовчуванням. + + + Виберіть пристрій + %1$s: «%2$s» + Відтворити + Додати до черги + Відтворити наступним + Цей пристрій + На цьому пристрої + Віддалений пристрій + Віддалений пристрій (%1$s) + На підключеному пристрої + + + Не вдалося підключитися + З’єднання втрачено + Не вдалося надіслати + Помилка пошуку: %1$s + Не вдалося підключитися: %1$s + + + Пристрої + Оновити + Підключено до %1$s:%2$d + Від’єднатися + Підключення... + Пошук пристроїв у мережі... + Пристроїв не знайдено + Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування». + Невідомий пристрій + Визначення адреси... + ID: %1$s... + + + Віддалене керування + Від’єднано + Помилка підключення: %1$s + Обкладинка альбому + Невідомий трек + Невідомий виконавець + Перемотування + Перемішати + Попередній + Наступний + Режим повтору + Гучність + Видалити з черги + Інші дії + + + Де відтворити? + У вас активний джем. Виберіть, де відтворити цю добірку. + Відтворити тут + Запропонувати в джем + + + Брокер для джему не налаштовано + Не вказано хост брокера + Не вдалося підключитися + З’єднання втрачено + + + Брокер + anonymous + + + Додано до черги джему + + %1$d трек додано до черги джему + %1$d треки додано до черги джему + %1$d треків додано до черги джему + %1$d трека додано до черги джему + + + + Запит на віддалене керування + «%1$s» хоче керувати відтворенням на цьому пристрої. + Відхилити + Дозволити + Дозволяти завжди + + + Результати пошуку + Усі + Треки + Користувачі + Немає доступних типів пошуку + Почніть вводити запит + Почніть вводити, щоб знайти треки + Нічого не знайдено + Показати всі + Пошук + Завантаження треків... + Завантаження альбомів... + Завантаження виконавців... + Завантаження плейлистів... + Завантаження користувачів... + Альбомів не знайдено + Виконавців не знайдено + Плейлистів не знайдено + Користувачів не знайдено + Риємося в ящиках із платівками... + Шукаємо по всьому всесвіту... + Розігріваємо колонки... + Здуваємо пил із вінілу... + Радимося з богами музики... + Налаштовуємо антени... + Перебираємо платівки... + Не вдалося завантажити можливості пошуку + Не вдалося виконати пошук + Не вдалося знайти треки + Не вдалося знайти альбоми + Не вдалося знайти виконавців + Не вдалося знайти плейлисти + Не вдалося знайти користувачів + + + Огляд + + + Синхронний + Простий + Синхронізований текст недоступний + Текст недоступний + + + %1$s у %2$s + %1$d kbps + %1$s kHz • %2$d кан. + Плагін метаданих не вибрано + + + Назад + Повторити + Закрити вікно + Завантаження... + Невідомо + Невідома помилка + + + Користувач + Плейлист + + + Нам дуже прикро + Щось пішло не так. Нам важливі ваші враження, і ми робимо все, щоб це виправити. Спробуйте ще раз або перегляньте подробиці помилки нижче. + Подробиці помилки + Копіювати в буфер обміну + Показати повний журнал + Повний журнал помилки + + + Перемішати + Перемішати й слухати + Підписатися + Відписатися + Редагувати плейлист + Автор: %1$s + + + Дії з треком + Додати в джем + Запустити радіо + Відтворити наступним + Зробити наступним + Видалити з черги + Видалити з улюблених + Додати в улюблені + Додати в плейлист + Завантажити + Поділитися + + + Початковий порядок + Назва + Виконавець + Альбом + Тривалість + Сортування + Треків не знайдено + Фільтр + Дії з вибраними + Завантажити всі + Завантажити (%1$d) + Додати всі до черги + Додати до черги (%1$d) + Відтворити всі наступними + Відтворити наступними (%1$d) + Додати всі в плейлист + Додати в плейлист (%1$d) + Додати всі в джем + Додати в джем (%1$d) + і ще %1$d + + + Немає URL для завантаження + Назад в історії браузера + Уперед в історії браузера + + + Фільтр чорного списку... + Чорний список порожній + У чорному списку немає збігів із «%1$s» + Треки + Трек + Додати до чорного списку + Видалити з чорного списку + + + Немає метаданих треку + Не вдалося отримати URL потоку + Не вдалося отримати URL потоку. Спершу спробуйте відтворити трек. + + + Збережені треки + Ви + + + Ви підписані + Популярні треки + Слухати популярні треки + Додати популярні треки до черги + Схожі виконавці + Рекомендовані плейлисти + Не вдалося завантажити сторінку виконавця + Кількість підписників недоступна + Підписників: %1$s тис. + Підписників: %1$s млн + Підписників: %1$s млрд + + %1$d підписник + %1$d підписники + %1$d підписників + %1$d підписника + + + + Подобається + Більше не подобається + Назва треку-заглушки + Виконавці-заглушки + Невідомий альбом + Перемотування + Гучність + Увімкнути перемішування + Вимкнути перемішування + Попередній + Наступний + Відтворити або призупинити + Повтор вимкнено + Повтор треку + Повтор усіх + Вимкнути звук + Увімкнути звук + Інші дії + Параметри плеєра + Згорнути плеєр + Розгорнути текст + Зараз грає + Текст + Альтернативне джерело + Альтернативне джерело + Завантажити + Завантажено + Завантаження... + Уже завантажено + Повторне завантаження... + Завантаження: %1$s + Відтворення призупиниться через %1$s. + Призупинити відтворення через вибрану кількість хвилин. + Хвилини + Від 1 до 600 хвилин + Скасувати таймер + Запустити + URL відтворення отримано з іншими налаштуваннями стримінгу. Виділений потік відповідає вашим поточним налаштуванням; активний URL оновиться під час наступного запиту потоку. + Підібране джерело аудіо + Завантаження підібраного джерела… + Віддалене джерело не потрібне, поки відтворюється локальний кешований файл. + Відповідне джерело недоступне. + Для цього треку немає кешованого підібраного джерела аудіо. + Відомості про потоки ще не кешовано. Спершу відтворіть трек, щоб завантажити їх. + Не вдалося завантажити відомості про трек. + Доступні потоки + Цей трек відтворюється з локального кешу. Варіанти потоків зараз не використовуються. + Завантаження потоків… + Для цього джерела немає доступних потоків. + Вибраний потік + Відповідає вашим налаштуванням формату та якості стримінгу + Активний URL відтворення + З локального кешу + Трек із метаданих + Назва + ID + Альбом + Точність збігу + Зовнішній URI + ID альбому + Тип альбому + Дата виходу альбому + Треків в альбомі + Жанри альбому + Тривалість + Номер треку + Номер диска + Відвертий контент + Популярність + ISRC + URL мініатюр + + + Показати або сховати бічну панель + + + Альтернативні джерела + Зараз нічого не грає + Пошук альтернативних джерел... + Альтернативних джерел не знайдено + Збіг: %1$d% + Поточне джерело + + + Вигнати + Видалити учасника із сесії + Вигнати й заборонити повертатися + Видалити пропозиції + Видалити всі треки, які учасник додав до черги + Змінити порядок + Видалити з черги + %1$s • Додав(-ла) %2$s + + + Невідомо + Завантаження плейлиста... + Додати треки + Додати треки в плейлист + Пошук треків... + Пошук + Треків не знайдено + Знайдіть треки, щоб додати їх + Готово + Додати + Додано + + + Завантаження альбому... + + + Додати у ваш плейлист + Пошук + Пошук у ваших плейлистах... + Завантаження ваших плейлистів... + У вас поки немає власних плейлистів.\nСтворіть плейлист, щоб додавати до нього треки. + Новий плейлист + Редагувати плейлист + Створити + Назва + Назва плейлиста + Опис + Опис (необов’язково) + Публічний + Цей плейлист бачать усі + Спільний + Інші можуть додавати треки до цього плейлиста + Обкладинка + Обкладинка плейлиста + Немає зображення + Змінити зображення + Вибрати зображення + + + Встановити + Замінити + У zip-архіві не знайдено plugin.json + У zip-архіві не знайдено manifest.zipline.json + Неправильний формат plugin.json: %1$s + Не вдалося прочитати плагін: %1$s + Не вдалося встановити плагін: %1$s + Доступна новіша версія %1$s. Підтвердьте оновлення перед встановленням. + %1$s уже встановлено. Підтвердьте заміну перед встановленням. + Вбудовані плагіни вже включено, їх не можна додати повторно. + Вбудовані плагіни не можна видалити. + %1$s вказує недійсну apiVersion (%2$s). Очікується семантична версія, сумісна з %3$s. + Недійсна версія API плагінів Spotube: %1$s. + %1$s розрахований на API плагінів %2$s, а Spotube підтримує несумісну версію API %3$s. Встановіть плагін, зібраний для тієї самої основної версії API. + + + Несумісний API плагіна + Встановити плагін? + %1$s буде додано до встановлених плагінів. + Оновити плагін? + Встановлено старішу версію (%1$s). Оновити до %2$s? + Замінити встановлений плагін? + %1$s використовує недійсну семантичну версію (%2$s), тому її не можна порівняти як оновлення. Усе одно замінити встановлений плагін? + Встановлену версію (%1$s) не можна порівняти за правилами семантичного версіонування. Замінити її на %2$s? + Версію %1$s уже встановлено. Замінити наявний плагін новою копією? + Встановлена версія %1$s новіша за запропоновану версію %2$s. Усе одно замінити? + Замінити встановлений плагін новою копією? + + + %1$s: + Версія + Версія API + Автор + Ліцензія + Дозволи + Функції + Репозиторій + Контакти + Повідомити про помилку + Підтримати %1$s + + + Офіційний + Перевірений + Зірки на GitHub + URL репозиторію на GitHub + + + Релізів не знайдено + Виберіть реліз + Виберіть реліз + Попередній реліз + Примітки до випуску + + + Головна + Пошук + На пристрої + Підключення + Пристрої + + + Сховати вікно + Показати вікно + Наступний трек + Попередній трек + Перемішати + Повтор + Повтор: ВИМК + Повтор: ТРЕК + Повтор: УСІ + Гучність: %1$d% + Гучність + + Гучність - + Вимкнути звук + Увімкнути звук + Подобається + Більше не подобається + Вийти + + + Згорнути + Розгорнути + Відновити + Закрити + + + Служба відтворення + Відтворення + Керування відтворенням + + + Огляд + Зараз грає + Рекомендоване + Збережені треки + Популярні треки + diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml new file mode 100644 index 00000000..4b81d8d0 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -0,0 +1,932 @@ + + + + Danh sách chờ + Xóa danh sách chờ + Lọc danh sách chờ... + Danh sách chờ trống + + Tìm bài hát, nghệ sĩ, album... + Xóa toàn bộ lịch sử + Tìm kiếm... + + + Thư viện của bạn + Thư viện + Danh sách phát + Album + Nghệ sĩ + Bài hát trên máy + Đã tải xuống + Tìm danh sách phát đã lưu… + Tìm album đã lưu… + Tìm nghệ sĩ đã lưu… + Tìm thư mục hoặc bài hát… + Lọc thư viện + Đóng bộ lọc + Tạo danh sách phát + Làm mới bài hát trên máy + Chuyển sang dạng lưới + Chuyển sang dạng danh sách + Chưa có danh sách phát nào được lưu + Không có danh sách phát nào khớp với “%1$s” + Chưa có album nào được lưu + Không có album nào khớp với “%1$s” + Chưa có nghệ sĩ nào được lưu + Không có nghệ sĩ nào khớp với “%1$s” + + + Thư mục + Thư mục + %1$s • %2$s + + %1$d bài hát + + + %1$d thư mục + + Quay lại thư mục + Phát thư mục + Cần quyền truy cập âm thanh + Cho phép truy cập âm thanh để duyệt nhạc trên thiết bị của bạn. + Cấp quyền + Không tìm thấy thư mục nào trên máy + Thư mục này không có bài hát nào + Không có thư mục hoặc bài hát nào khớp + Không thể làm mới nội dung trên máy + + + %1$d đang tải • %2$d đã xong + Xóa mục đã xong + Chưa có nội dung tải xuống + Đang tải xuống %1$d% + Đang chờ + Đã xong + Đã hủy + Thất bại + Thất bại: %1$s + Hủy tải xuống + Thử tải lại + Xóa mục tải xuống + + + Nghệ sĩ + Đã chặn + Bài hát đã thích + Bài hát bạn đã lưu + Nghệ sĩ không xác định + Tên bài hát + Tên nghệ sĩ + + Đã chọn + + Cài đặt + + Plugin + Ngôn ngữ và khu vực + Giao diện + Phát lại + Tải xuống + Bộ nhớ đệm + Máy tính + Cập nhật + Ủng hộ + Giới thiệu + + Đóng + Lưu + Hủy + + Ngôn ngữ + Ngôn ngữ giao diện ứng dụng. Hiện tại: %1$s + Mặc định của hệ thống + Quốc gia + Quốc gia dùng cho nội dung và tính khả dụng. Hiện tại: %1$s + %1$s (%2$s) + + Chủ đề + Chọn cách Spotube khớp với giao diện hệ thống. Hiện tại: %1$s + Sáng + Tối + Theo hệ thống + + Bề mặt + Chất liệu nền của ứng dụng. Hiện tại: %1$s + Nước + Băng + Chrome + Acrylic + Kính + + Màu nhấn + Bảng màu hiện tại: %1$s + Màu nhấn + Chọn bảng màu nhấn và xem trước ở cả chủ đề sáng và tối. + Sáng + Tối + Xem trước + Nút + Xanh yêu tinh + Tím điện + Lam đại dương + Cam hoàng hôn + Vườn hồng + Xanh nửa đêm + Xám kim loại + + Giao diện & cảm nhận + Chất liệu bề mặt và màu nhấn. Hiện tại: %1$s · %2$s + Giao diện & cảm nhận + Chọn chất liệu bề mặt, sau đó chọn màu nhấn phù hợp. Mỗi lựa chọn đều là một sự kết hợp được tuyển chọn. + Bề mặt + Màu nhấn + Phụ + + Bảng màu + Bảng màu nhấn Millennium. Hiện tại: %1$s + Bảng màu + Chọn bảng màu nhấn Millennium và xem trước ở chế độ sáng và tối. + Bảng màu + Bảng màu Millennium và màu nhấn. Hiện tại: %1$s + Xanh lam + Cam + Xanh chanh + + Thư mục tải xuống tùy chỉnh + Thư mục hiện tại: %1$s + Thư mục hiện tại: thư mục nhạc mặc định + Để trống để dùng thư mục nhạc mặc định. + C:\Music\Spotube + Thư mục nhạc trên máy + Thư mục tùy chỉnh để quét trên máy tính. Đã thêm: %1$d + Chưa thêm thư mục tùy chỉnh nào + Thư mục tùy chỉnh cho thư viện nhạc trên máy của bạn. + Quản lý + Thêm thư mục + Xóa thư mục + Chưa thêm thư mục tùy chỉnh nào. + Xóa %1$s khỏi danh sách quét + Thư mục tải xuống và thư mục bộ nhớ đệm nhạc được tự động thêm vào. + Thư mục tải xuống của bạn được tự động thêm vào. + Thư mục bộ nhớ đệm nhạc được tự động thêm vào. + Đang lưu… + Không thể lưu thư mục. Các thay đổi của bạn vẫn còn đây; hãy thử lại. + + %1$d thư mục + + Định dạng nhạc tải xuống + Codec và container ưu tiên khi tải xuống. Hiện tại: %1$s + Chất lượng nhạc tải xuống + + Định dạng nhạc phát trực tuyến + Codec và container ưu tiên khi phát trực tuyến. Hiện tại: %1$s + Chất lượng nhạc phát trực tuyến + Hiện tại: %1$s + Bật bộ nhớ đệm nhạc + Lưu âm thanh đã phát trực tuyến trên máy để phát lại nhanh hơn. + Thư mục bộ nhớ đệm + Hiện tại: %1$s + Hiện tại: bộ nhớ đệm mặc định của ứng dụng + Giới hạn dung lượng bộ nhớ đệm + Không giới hạn + Giới hạn hiện tại: %1$d MB + Đặt dung lượng bộ nhớ đệm tối đa tính bằng MB. Nhập 0 hoặc để trống nếu không muốn giới hạn. + 500 + Dung lượng phải là 0 hoặc một số dương. + Bật phát liên tục + Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc. + Bật Connect + Cung cấp điều khiển phát từ xa qua tính năng Spotube Connect. + Cổng máy chủ proxy phát lại + Cho phép điều khiển từ xa + Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối. + Tên thiết bị điều khiển từ xa + Tên mà các thiết bị khác thấy khi tìm thấy thiết bị này. Hiện tại: %1$s + Chọn một tên dễ nhận biết để hiển thị cho các thiết bị khác trong mạng khi chúng tìm thấy Spotube này. + VD: Spotube phòng khách + tên máy chủ của thiết bị + Cổng được máy chủ proxy phát lại sử dụng. Hiện tại: %1$d + Chọn một cổng từ 1 đến 65535. + 14769 + Nhập một số nguyên hợp lệ. + Cổng phải nằm trong khoảng từ 1 đến 65535. + + Danh sách chặn + Quản lý bài hát và nghệ sĩ bị chặn + + Thu nhỏ xuống khay hệ thống + Giữ Spotube chạy trong khay hệ thống khi thu nhỏ cửa sổ. + Discord Rich Presence + Hiển thị trạng thái đang phát trên Discord trên máy tính. + + Tự động kiểm tra cập nhật + Tìm các phiên bản Spotube mới trong nền. + + Quản lý plugin + Quản lý các plugin của bạn + Plugin %1$s mặc định + Siêu dữ liệu + Âm thanh + Lời bài hát + Scrobble + Chưa chọn plugin + Thay đổi + Chọn + Đã chọn + Bỏ chọn + Không có plugin nào + Plugin %1$s + Xóa + + Cấu hình + Trình quản lý plugin + Vui lòng nhập URL + URL phải bắt đầu bằng http:// hoặc https:// + Không thể tải plugin xuống + Chưa cài đặt plugin nào + Dán URL tải xuống hoặc chọn tệp ở trên. + + Đã cài đặt %1$d plugin + + Cài đặt plugin + https://example.com/plugin.smplug + Tải xuống + Cài đặt từ tệp (.smplug) + Đang dùng + v%1$s + Tích hợp sẵn + Gỡ plugin + Đăng nhập + Đăng xuất + Tải xuống từ URL + Cài đặt từ tệp + Cài đặt + + • %1$s + Quyền được yêu cầu + Plugin này không yêu cầu quyền đặc biệt nào. + Đã cài đặt và bản cung cấp + Đã cài đặt + Bản cung cấp + API %1$s → %2$s + Lưu trữ lâu dài + Đọc và ghi dữ liệu vào ổ đĩa + Yêu cầu mạng + Gửi và nhận dữ liệu qua internet + WebView + Hiển thị nội dung web trong ứng dụng + Jam nhóm + MQTT Broker + Máy chủ broker + %1$s:%2$d + Cổng broker + Dùng TLS + Tên người dùng (không bắt buộc) + Mật khẩu (không bắt buộc) + Tiền tố Client ID + Dùng để nhận diện Spotube này + Mã hóa kết nối tới broker + Kiểm tra cài đặt broker hiện tại trước khi tạo phòng. + Kiểm tra kết nối + Đang kiểm tra… + Đã kết nối trong %1$d ms + Thất bại: %1$s + Broker tạm thời — hãy cấu hình broker riêng để tự lưu trữ + Kết nối + Thông tin đăng nhập + Danh tính + Chẩn đoán + + + Jam nhóm + Tạo + Tham gia + Tạo phòng, sau đó chia sẻ mã phòng với bạn bè. + Tạo phòng + Vào phòng + Đang kết nối… + Mã phòng + ABC123 + Broker: %1$s + Hãy thiết lập broker trong Cài đặt để tạo hoặc tham gia phòng. + Cài đặt broker + Đã kết nối + Đang kết nối lại… + Sao chép mã + Đã sao chép! + Chia sẻ + Người tham gia (%1$d) + Chủ phòng + Người nghe + Đang chờ người nghe… + Xóa + Cấm + Quản lý + Rời phòng + Không thể kết nối + Bỏ qua + Không thể tạo phiên Jam + Không thể tham gia phiên Jam + Nhập mã phòng gồm sáu ký tự chữ và số. + Tham gia Jam Spotube của mình với mã: %1$s + + Đi tới album + Hẹn giờ tắt + Chi tiết bài hát + Phát + Tạm dừng + Không có gì đang phát + Thêm vào danh sách chờ + + Bỏ qua + Cập nhật + Đã có Spotube %1$s + Xem có gì mới trong bản phát hành này trên trang tải xuống Spotube. + Đã có %1$s %2$s + Đang cài đặt: %1$s + Xem có gì mới trong bản cập nhật này trên trang của plugin. + + Bỏ qua + Tiếp + Quay lại + Bắt đầu + Spotube + Âm nhạc, không ràng buộc. + Trình phát mã nguồn mở cho cả thế giới âm nhạc.\nTùy bạn khám phá. Tùy bạn biến nó thành của riêng mình. + Giúp dự án này tiếp tục sống. + Spotube được tạo ra bởi con người, không phải một doanh nghiệp thu phí thuê bao. Nếu Spotube trở thành một phần cuộc sống hằng ngày của bạn, một chút ủng hộ sẽ giúp chúng tôi chăm chút nó xứng đáng hơn.\n\nCảm ơn bạn đã dành chỗ cho phần mềm độc lập. + Bắt đầu với một plugin danh mục để tìm bài hát và danh sách phát. Chọn một plugin âm thanh để phát chúng. Lời bài hát và lịch sử nghe là tùy chọn. Hãy khám phá plugin bên dưới, xem lại quyền của nó, cài đặt rồi chọn nó tại đây. + Khám phá plugin + Nguồn của bạn + Thử lại + Thêm plugin + Không tìm thấy plugin mới. Bạn cũng có thể cài đặt từ liên kết hoặc tệp .smplug ở trên. + Hiện không thể khám phá plugin. Hãy thử lại hoặc cài đặt từ liên kết hay tệp. + Chào mừng + Ủng hộ + Plugin + Hướng dẫn + + Cách dùng Spotube + Bốn bước nhanh là bạn đã có thể nghe nhạc. + Đăng nhập + Kết nối tài khoản Spotify hoặc chọn plugin danh mục của riêng bạn. + Tìm kiếm và phát + Tìm bất kỳ bài hát, album hay nghệ sĩ nào rồi nhấn phát. + Nghe ngoại tuyến + Tải bài hát xuống để tiếp tục nghe khi không có kết nối. + Theo dõi lời bài hát + Mở lời bài hát đồng bộ từ trình phát khi bài hát đang phát. + + + Âm nhạc của bạn. Nguồn của bạn. Luật của bạn. + Trình phát nhạc miễn phí, mã nguồn mở, được xây dựng bởi một cộng đồng yêu âm nhạc. + Mã nguồn mở + Không quảng cáo + Không thu thập dữ liệu + Vận hành bằng plugin + Bắt đầu thôi + + + Người ủng hộ giúp chúng tôi tiếp bước + Tạo nên bằng đam mê. Duy trì nhờ bạn. + Spotube miễn phí 100%, tự do và mã nguồn mở — không quảng cáo, không thuê bao, không theo dõi. Mọi tính năng tồn tại là nhờ những người như bạn đã chọn giúp mã nguồn tiếp tục phát triển. + Patreon + Tài trợ hằng tháng + Hỗ trợ phát triển và hạ tầng lâu dài + Buy Me a Coffee + Một ly cà phê + Tiếp sức cho một buổi code đêm khuya bằng một khoản tip nhỏ + Open Collective + Quỹ cộng đồng + Hỗ trợ tài chính minh bạch, do cộng đồng quản lý + Từ tận đáy lòng, cảm ơn bạn đã tin vào âm nhạc mở. + Ủng hộ hoàn toàn là tùy chọn — Spotube vẫn luôn miễn phí. + Tài trợ + Mở %1$s + + + Ủng hộ Spotube + Ủng hộ Spotube + Spotube miễn phí, tự do và mã nguồn mở — không quảng cáo, không thuê bao, không theo dõi. Nếu Spotube mang lại niềm vui cho bạn, hãy cân nhắc ủng hộ dự án. + Bạn có thể tắt lời nhắc này trong Cài đặt → Ủng hộ. + Để sau + Lời nhắc ủng hộ + Hiển thị lời nhắc nhẹ nhàng về việc ủng hộ dự án mỗi 7 ngày. + + + Kiến trúc mô-đun + Chọn nơi lấy nhạc của bạn. + Spotube tách riêng việc khám phá danh mục, phát âm thanh trực tuyến và lời bài hát đồng bộ. Cấu hình các engine bên dưới hoặc chuyển đổi bất cứ lúc nào. + Engine siêu dữ liệu + Cung cấp khám phá nhạc, tìm kiếm, danh sách bài hát và danh sách phát. + Engine âm thanh + Lấy và giải mã luồng âm thanh để phát. + Engine lời bài hát + Truyền lời bài hát karaoke đồng bộ theo thời gian thực. + Engine scrobble + Ghi lại lịch sử nghe lên Last.fm hoặc ListenBrainz. + Đang dùng + Tích hợp sẵn + Sẵn sàng + Tùy chọn + Chọn một engine + Engine đã cài đặt + Đặt làm mặc định + NewPipe YouTube và LRCLib Lyrics được tích hợp sẵn và có thể phát ngay. + Quản lý hoặc thêm plugin cộng đồng bất cứ lúc nào trong Cài đặt → Plugin. + + + Kiến trúc cổ điển hiện đại + Hi-Fi thuần túy, không trung gian + Đây là cách Spotube điều phối việc phát nhạc phi tập trung ngay trên máy của bạn. + NGANG HÀNG TRỰC TIẾP • 256 KBPS OPUS + Giờ xanh + Spotube · minh họa trình phát + Một chút âm nhạc cho những khoảnh khắc xen giữa. + 1. Khám phá phi tập trung + Truy vấn được gửi thẳng tới plugin danh mục bạn chọn. Spotube không bao giờ lập hồ sơ sở thích của bạn. + 2. Phân giải luồng trực tiếp + Âm thanh được truyền trực tiếp từ máy chủ nội dung tới máy của bạn. Không qua proxy, không thu thập dữ liệu từ xa. + 3. DSP & phát lại cục bộ + Giải mã và lưu đệm cục bộ với bộ chỉnh âm và tùy chọn tải xuống ngoại tuyến. + 4. Lời bài hát đồng bộ & Jam + Lời karaoke bám theo thời điểm phát, cùng với điều khiển từ xa qua LAN và Jam nhóm. + Không có máy chủ trung gian. Việc phát nhạc diễn ra ngay trên thiết bị của bạn — riêng tư từ thiết kế. + + + Chọn thiết bị + %1$s: “%2$s” + Phát + Thêm vào danh sách chờ + Phát tiếp theo + Thiết bị này + Trên thiết bị này + Thiết bị từ xa + Thiết bị từ xa (%1$s) + Trên thiết bị đã kết nối + + + Kết nối thất bại + Mất kết nối + Gửi thất bại + Tìm thiết bị thất bại: %1$s + Không thể kết nối: %1$s + + + Thiết bị + Làm mới + Đã kết nối với %1$s:%2$d + Ngắt kết nối + Đang kết nối... + Đang tìm thiết bị trong mạng... + Không tìm thấy thiết bị nào + Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt. + Thiết bị không xác định + Đang phân giải... + ID: %1$s... + + + Điều khiển từ xa + Đã ngắt kết nối + Lỗi kết nối: %1$s + Ảnh bìa album + Bài hát không xác định + Nghệ sĩ không xác định + Tua + Phát ngẫu nhiên + Trước + Tiếp + Chế độ lặp + Âm lượng + Xóa khỏi danh sách chờ + Tùy chọn khác + + + Phát ở đâu? + Bạn đang có một phiên Jam. Chọn nơi phát bộ sưu tập này. + Phát tại đây + Đề xuất cho Jam + + + Chưa cấu hình broker cho Jam + Máy chủ broker đang trống + Kết nối thất bại + Mất kết nối + + + Broker + ẩn danh + + + Đã thêm vào danh sách chờ của Jam + + Đã thêm %1$d bài hát vào danh sách chờ của Jam + + + + Yêu cầu điều khiển từ xa + \"%1$s\" muốn điều khiển việc phát trên thiết bị này. + Từ chối + Cho phép + Luôn cho phép + + + Kết quả tìm kiếm + Tất cả + Bài hát + Người dùng + Không có loại tìm kiếm nào + Nhập để bắt đầu tìm kiếm + Nhập để bắt đầu tìm bài hát + Không tìm thấy kết quả + Xem tất cả + Đang tìm kiếm + Đang tải bài hát... + Đang tải album... + Đang tải nghệ sĩ... + Đang tải danh sách phát... + Đang tải người dùng... + Không tìm thấy album nào + Không tìm thấy nghệ sĩ nào + Không tìm thấy danh sách phát nào + Không tìm thấy người dùng nào + Đang lục tìm kho đĩa... + Đang tìm khắp vũ trụ... + Đang làm nóng loa... + Đang phủi bụi đĩa than... + Đang hỏi ý các vị thần âm nhạc... + Đang dò ăng-ten... + Đang lật giở các đĩa nhạc... + Không thể tải khả năng tìm kiếm + Tìm kiếm thất bại + Không thể tìm bài hát + Không thể tìm album + Không thể tìm nghệ sĩ + Không thể tìm danh sách phát + Không thể tìm người dùng + + + Duyệt + + + Đồng bộ + Thường + Không có lời bài hát đồng bộ + Không có lời bài hát + + + %1$s trong %2$s + %1$d kbps + %1$s kHz • %2$d kênh + Chưa chọn plugin siêu dữ liệu + + + Quay lại + Thử lại + Đóng hộp thoại + Đang tải... + Không xác định + Lỗi không xác định + + + Người dùng + Danh sách phát + + + Chúng tôi thật sự xin lỗi + Đã có sự cố xảy ra. Trải nghiệm của bạn rất quan trọng với chúng tôi và chúng tôi đang cố gắng hết sức để khắc phục. Vui lòng thử lại hoặc xem chi tiết lỗi bên dưới. + Chi tiết lỗi + Sao chép vào bộ nhớ tạm + Xem nhật ký đầy đủ + Nhật ký lỗi đầy đủ + + + Phát ngẫu nhiên + Phát ngẫu nhiên + Theo dõi + Bỏ theo dõi + Sửa danh sách phát + Của %1$s + + + Tùy chọn bài hát + Thêm vào Jam + Phát radio + Phát tiếp theo + Chuyển lên phát tiếp theo + Xóa khỏi danh sách chờ + Xóa khỏi mục yêu thích + Lưu vào mục yêu thích + Thêm vào danh sách phát + Tải xuống + Chia sẻ + + + Mặc định + Tên + Nghệ sĩ + Album + Thời lượng + Sắp xếp + Không tìm thấy bài hát nào + Lọc + Thao tác hàng loạt + Tải xuống tất cả + Tải xuống %1$d + Thêm tất cả vào danh sách chờ + Thêm %1$d vào danh sách chờ + Phát tất cả tiếp theo + Phát %1$d bài tiếp theo + Thêm tất cả vào danh sách phát + Thêm %1$d vào danh sách phát + Thêm tất cả vào Jam + Thêm %1$d vào Jam + +%1$d người khác + + + Không có URL để tải + Quay lại trang trước + Đi tới trang sau + + + Lọc danh sách chặn... + Không có mục nào bị chặn + Không có mục bị chặn nào khớp với \'%1$s\' + Bài hát + Bài hát + Thêm vào danh sách chặn + Xóa khỏi danh sách chặn + + + Không có siêu dữ liệu bài hát + Không thể phân giải URL luồng + Không thể phân giải URL luồng. Hãy thử phát bài hát trước. + + + Bài hát đã lưu + Bạn + + + Đang theo dõi + Bài hát hàng đầu + Phát bài hát hàng đầu + Thêm bài hát hàng đầu vào danh sách chờ + Nghệ sĩ liên quan + Danh sách phát nổi bật + Không thể tải thông tin nghệ sĩ + Không có thông tin người theo dõi + %1$s N người theo dõi + %1$s Tr người theo dõi + %1$s T người theo dõi + + %1$d người theo dõi + + + + Thích + Bỏ thích + Tên bài hát trống + Nghệ sĩ trống + Album không xác định + Tua + Âm lượng + Bật phát ngẫu nhiên + Tắt phát ngẫu nhiên + Trước + Tiếp + Phát hoặc tạm dừng + Tắt lặp lại + Lặp lại một bài + Lặp lại tất cả + Tắt tiếng + Bật tiếng + Tùy chọn khác + Tùy chọn trình phát + Thu gọn trình phát + Mở rộng lời bài hát + Đang phát + Lời bài hát + Nguồn thay thế + Nguồn thay thế + Tải xuống + Đã tải xuống + Đang tải xuống... + Đã tải xuống trước đó + Đang thử tải lại... + Đang tải xuống %1$s + Nhạc sẽ tạm dừng sau %1$s. + Tạm dừng phát sau số phút đã chọn. + Phút + Chọn từ 1 đến 600 phút + Hủy hẹn giờ + Bắt đầu + URL phát được phân giải với cài đặt phát trực tuyến khác. Luồng được đánh dấu khớp với lựa chọn hiện tại của bạn; URL đang dùng sẽ được làm mới ở lần yêu cầu luồng tiếp theo. + Nguồn âm thanh khớp + Đang tải nguồn khớp… + Không cần nguồn từ xa khớp khi đang phát tệp đệm trên máy. + Không có nguồn khớp nào. + Chưa có nguồn âm thanh khớp nào được lưu đệm cho bài hát này. + Chi tiết luồng chưa được lưu đệm. Hãy phát bài hát trước để tải chúng. + Không thể tải chi tiết bài hát. + Các luồng khả dụng + Bài hát này đang được phát từ bộ nhớ đệm trên máy. Các biến thể luồng không được dùng cho lần phát này. + Đang tải luồng… + Không có luồng nào cho nguồn này. + Luồng đang được chọn + Khớp với định dạng & chất lượng phát trực tuyến bạn chọn + URL phát đang dùng + Phát từ bộ nhớ đệm trên máy + Bài hát theo siêu dữ liệu + Tên + ID + Album + Độ tin cậy + URI bên ngoài + ID album + Loại album + Ngày phát hành album + Số bài hát trong album + Thể loại album + Thời lượng + Số thứ tự bài hát + Số đĩa + Nội dung tục tĩu + Độ phổ biến + ISRC + URL ảnh thu nhỏ + + + Bật/tắt thanh bên + + + Nguồn thay thế + Hiện không có bài hát nào đang phát + Đang tìm nguồn thay thế... + Không tìm thấy nguồn thay thế nào + Độ tin cậy: %1$d% + Nguồn đang dùng + + + Mời ra + Xóa người này khỏi phiên + Mời ra và không cho tham gia lại + Xóa đề xuất + Xóa mọi bài hát họ đã thêm vào danh sách chờ + Sắp xếp lại + Xóa khỏi danh sách chờ + %1$s • Được thêm bởi %2$s + + + Không xác định + Đang tải danh sách phát... + Thêm bài hát + Thêm bài hát vào danh sách phát + Tìm bài hát... + Tìm kiếm + Không tìm thấy bài hát nào + Tìm bài hát để thêm + Xong + Thêm + Đã thêm + + + Đang tải album... + + + Thêm vào danh sách phát của bạn + Tìm kiếm + Tìm danh sách phát của bạn... + Đang tải danh sách phát của bạn... + Bạn chưa có danh sách phát nào.\nHãy tạo một danh sách để thêm bài hát. + Tạo danh sách phát + Sửa danh sách phát + Tạo + Tên + Tên danh sách phát + Mô tả + Mô tả (không bắt buộc) + Công khai + Ai cũng có thể xem danh sách phát này + Cộng tác + Người khác có thể thêm bài hát vào danh sách phát này + Ảnh bìa + Ảnh bìa danh sách phát + Không có ảnh + Đổi ảnh + Chọn ảnh + + + Cài đặt + Thay thế + Không tìm thấy plugin.json trong tệp zip + Không tìm thấy manifest.zipline.json trong tệp zip + Định dạng plugin.json không hợp lệ: %1$s + Không thể đọc plugin: %1$s + Không thể cài đặt plugin: %1$s + Đã có phiên bản mới hơn của %1$s. Hãy xác nhận cập nhật trước khi cài đặt. + %1$s đã được cài đặt. Hãy xác nhận thay thế trước khi cài đặt. + Plugin tích hợp sẵn đã có trong ứng dụng và không thể thêm lại. + Không thể gỡ plugin tích hợp sẵn. + %1$s khai báo apiVersion không hợp lệ (%2$s). Cần phiên bản theo chuẩn semantic versioning tương thích với %3$s. + Phiên bản API plugin Spotube %1$s không hợp lệ. + %1$s nhắm tới API plugin %2$s, nhưng Spotube hỗ trợ API plugin %3$s có thay đổi không tương thích. Hãy cài đặt plugin được xây dựng cho cùng phiên bản API chính. + + + API plugin không tương thích + Cài đặt plugin? + %1$s sẽ được thêm vào danh sách plugin đã cài đặt. + Cập nhật plugin? + Đang cài đặt phiên bản cũ hơn (%1$s). Cập nhật lên %2$s? + Thay thế plugin đã cài đặt? + %1$s dùng phiên bản semantic không hợp lệ (%2$s) nên không thể so sánh như một bản cập nhật. Vẫn thay thế plugin đã cài đặt? + Không thể so sánh phiên bản đã cài đặt (%1$s) theo semantic versioning. Thay thế bằng %2$s? + Phiên bản %1$s đã được cài đặt. Thay thế plugin hiện có bằng bản được cung cấp? + Phiên bản đã cài đặt %1$s mới hơn phiên bản được cung cấp %2$s. Vẫn thay thế? + Thay thế plugin đã cài đặt bằng bản được cung cấp? + + + %1$s: + Phiên bản + Phiên bản API + Tác giả + Giấy phép + Khả năng + Chức năng + Kho mã + Liên hệ + Báo lỗi + Ủng hộ %1$s + + + Chính thức + Đã xác minh + Sao GitHub + URL kho mã GitHub + + + Không tìm thấy bản phát hành nào + Chọn bản phát hành + Chọn một bản phát hành + Bản thử nghiệm + Ghi chú phát hành + + + Trang chủ + Tìm kiếm + Trên thiết bị + Kết nối + Thiết bị + + + Ẩn cửa sổ + Hiện cửa sổ + Bài tiếp theo + Bài trước + Phát ngẫu nhiên + Lặp lại + Lặp lại: TẮT + Lặp lại: MỘT BÀI + Lặp lại: TẤT CẢ + Âm lượng: %1$d% + Âm lượng + + Âm lượng - + Tắt tiếng + Bật tiếng + Thích bài hát + Bỏ thích bài hát + Thoát + + + Thu nhỏ + Phóng to + Khôi phục + Đóng + + + Dịch vụ phát nhạc + Phát nhạc + Điều khiển phát nội dung + + + Duyệt + Đang phát + Nổi bật + Bài hát đã lưu + Bài hát hàng đầu + diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml new file mode 100644 index 00000000..6ab50884 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -0,0 +1,932 @@ + + + + 待播清單 + 清除待播清單 + 篩選待播清單... + 待播清單中沒有項目 + + 搜尋歌曲、藝人、專輯... + 清除所有紀錄 + 搜尋... + + + 你的音樂庫 + 音樂庫 + 播放清單 + 專輯 + 藝人 + 本機曲目 + 下載 + 搜尋已儲存的播放清單… + 搜尋已儲存的專輯… + 搜尋已儲存的藝人… + 搜尋資料夾或曲目… + 篩選音樂庫 + 關閉篩選 + 建立播放清單 + 重新整理本機曲目 + 切換為格狀檢視 + 切換為清單檢視 + 沒有已儲存的播放清單 + 沒有符合「%1$s」的播放清單 + 沒有已儲存的專輯 + 沒有符合「%1$s」的專輯 + 沒有已儲存的藝人 + 沒有符合「%1$s」的藝人 + + + 資料夾 + 資料夾 + %1$s • %2$s + + %1$d 首曲目 + + + %1$d 個資料夾 + + 返回資料夾 + 播放資料夾 + 需要音訊存取權限 + 允許存取音訊,即可瀏覽裝置上的音樂。 + 授予權限 + 找不到本機資料夾 + 此資料夾中沒有曲目 + 沒有符合的資料夾或曲目 + 無法重新整理本機媒體 + + + %1$d 個進行中 • %2$d 個已完成 + 清除已完成項目 + 尚無下載項目 + 下載中 %1$d% + 等待中 + 已完成 + 已取消 + 失敗 + 失敗:%1$s + 取消下載 + 重試下載 + 移除下載項目 + + + 藝人 + 已封鎖 + 喜歡的曲目 + 你已儲存的歌曲 + 未知藝人 + 曲目名稱 + 藝人名稱 + + 已選取 + + 設定 + + 外掛程式 + 語言與地區 + 外觀 + 播放 + 下載 + 快取 + 桌面版 + 更新 + 支持 + 關於 + + 關閉 + 儲存 + 取消 + + 語言 + 應用程式介面的語言。目前:%1$s + 跟隨系統 + 國家/地區 + 用於內容與可用性的國家/地區。目前:%1$s + %1$s(%2$s) + + 主題 + 選擇 Spotube 如何配合系統外觀。目前:%1$s + 淺色 + 深色 + 跟隨系統 + + 表面材質 + 應用程式的背景材質。目前:%1$s + 水波 + 冰晶 + 金屬鉻 + 壓克力 + 玻璃 + + 強調色 + 目前色盤:%1$s + 強調色 + 選擇強調色盤,並在淺色與深色主題中預覽效果。 + 淺色 + 深色 + 預覽 + 按鈕 + 妖精綠 + 電光紫 + 海洋青 + 夕陽橘 + 玫瑰園 + 午夜藍 + 金屬石板灰 + + 外觀與風格 + 表面材質與強調色。目前:%1$s · %2$s + 外觀與風格 + 先選擇表面材質,再挑選與之搭配的強調色。每個選項都是精心調配的組合。 + 表面材質 + 強調色 + 次要 + + 色盤 + Millennium 強調色盤。目前:%1$s + 色盤 + 選擇 Millennium 強調色盤,並在淺色與深色模式中預覽。 + 色盤 + Millennium 色盤與強調色。目前:%1$s + 藍色 + 橘色 + 萊姆綠 + + 自訂下載資料夾 + 目前資料夾:%1$s + 目前資料夾:預設音樂資料夾 + 留空即使用預設音樂資料夾。 + C:\Music\Spotube + 本機媒體資料夾 + 桌面版要掃描的自訂資料夾。已新增:%1$d + 尚未新增自訂資料夾 + 本機音樂庫的自訂資料夾。 + 管理 + 新增資料夾 + 移除資料夾 + 尚未新增自訂資料夾。 + 從掃描清單中移除 %1$s + 下載資料夾與音樂快取資料夾會自動納入。 + 你的下載資料夾會自動納入。 + 音樂快取資料夾會自動納入。 + 儲存中… + 無法儲存資料夾。你的變更仍保留在此,請再試一次。 + + %1$d 個資料夾 + + 下載音樂格式 + 下載時偏好的編碼與封裝格式。目前:%1$s + 下載音樂品質 + + 串流音樂格式 + 串流音樂時偏好的編碼與封裝格式。目前:%1$s + 串流音樂品質 + 目前:%1$s + 啟用音樂快取 + 將串流音訊儲存在本機,加快重複播放的速度。 + 快取資料夾 + 目前:%1$s + 目前:預設應用程式快取 + 快取大小上限 + 無上限 + 目前上限:%1$d MB + 設定快取大小上限(MB)。輸入 0 或留空表示無上限。 + 500 + 大小必須為 0 或正數。 + 啟用無限播放 + 待播清單播完後,以推薦曲目繼續播放。 + 啟用 Connect + 透過 Spotube Connect 功能提供遠端播放控制。 + 播放代理伺服器連接埠 + 允許遠端控制 + 讓同一網路中的其他裝置在連線後控制播放。 + 遠端控制裝置名稱 + 其他裝置搜尋到此裝置時所顯示的名稱。目前:%1$s + 選擇一個好記的名稱,讓網路上的其他裝置找到這個 Spotube 時顯示。 + 例如:客廳的 Spotube + 裝置主機名稱 + 播放代理伺服器使用的連接埠。目前:%1$d + 請選擇 1 到 65535 之間的連接埠。 + 14769 + 請輸入有效的整數。 + 連接埠必須介於 1 到 65535 之間。 + + 封鎖清單 + 管理已封鎖的曲目與藝人 + + 最小化至系統匣 + 視窗最小化時,讓 Spotube 在系統匣中繼續執行。 + Discord 動態狀態 + 在桌面版的 Discord 中顯示目前播放狀態。 + + 自動檢查更新 + 在背景檢查 Spotube 的新版本。 + + 管理外掛程式 + 管理你的外掛程式 + 預設%1$s外掛程式 + 中繼資料 + 音訊 + 歌詞 + 收聽紀錄 + 未選取外掛程式 + 變更 + 選取 + 已選取 + 清除選取 + 沒有可用的外掛程式 + %1$s 外掛程式 + 清除 + + 設定 + 外掛程式管理員 + 請輸入 URL + URL 必須以 http:// 或 https:// 開頭 + 無法下載外掛程式 + 尚未安裝任何外掛程式 + 請貼上下載 URL,或從上方選擇檔案。 + + 已安裝 %1$d 個外掛程式 + + 安裝外掛程式 + https://example.com/plugin.smplug + 下載 + 從檔案安裝(.smplug) + 使用中 + v%1$s + 內建 + 移除外掛程式 + 登入 + 登出 + 從 URL 下載 + 從檔案安裝 + 安裝 + + • %1$s + 要求的權限 + 此外掛程式不需要任何特殊權限。 + 已安裝與提供版本比較 + 已安裝 + 提供的版本 + API %1$s → %2$s + 永久儲存空間 + 讀取及寫入磁碟資料 + 網路要求 + 透過網際網路傳送及接收資料 + WebView + 在應用程式內顯示網頁內容 + 群組 Jam + MQTT 代理伺服器 + 代理伺服器主機 + %1$s:%2$d + 代理伺服器連接埠 + 使用 TLS + 使用者名稱(選填) + 密碼(選填) + 用戶端 ID 前綴 + 用於識別這個 Spotube + 加密與代理伺服器的連線 + 在建立房間前檢查目前的代理伺服器設定。 + 測試連線 + 測試中… + 已連線,耗時 %1$d 毫秒 + 失敗:%1$s + 預留的代理伺服器 — 請設定你自己的伺服器以自行架設 + 連線 + 憑證 + 身分 + 診斷 + + + 群組 Jam + 建立 + 加入 + 建立房間,然後將代碼分享給朋友。 + 建立房間 + 加入房間 + 連線中… + 房間代碼 + ABC123 + 代理伺服器:%1$s + 請先在「設定」中設定代理伺服器,才能建立或加入房間。 + 代理伺服器設定 + 已連線 + 重新連線中… + 複製代碼 + 已複製! + 分享 + 參與者(%1$d) + 主持人 + 聽眾 + 正在等待聽眾加入… + 移除 + 封鎖 + 管理 + 離開房間 + 無法連線 + 關閉 + 無法建立 Jam + 無法加入 Jam + 請輸入由英文字母和數字組成的六位房間代碼。 + 用代碼 %1$s 加入我的 Spotube Jam + + 前往專輯 + 睡眠定時器 + 曲目詳細資料 + 播放 + 暫停 + 目前沒有播放內容 + 加入待播清單 + + 略過 + 更新 + Spotube %1$s 已推出 + 前往 Spotube 下載頁面查看此版本的新功能。 + %1$s %2$s 已推出 + 目前安裝版本:%1$s + 前往外掛程式頁面查看此更新的新功能。 + + 略過 + 下一步 + 上一步 + 開始使用 + Spotube + 音樂,無拘無束。 + 一款開放原始碼的播放器,帶你走進音樂的世界。\n任你探索,由你打造。 + 讓這個專案延續下去。 + Spotube 是由一群人打造的,而不是靠訂閱賺錢的企業。如果它已成為你日常生活的一部分,一點點支持就能幫助我們給予它應有的照顧。\n\n感謝你為獨立軟體留出一席之地。 + 先從目錄外掛程式開始,用來尋找歌曲與播放清單。接著選擇音訊外掛程式來播放。歌詞與收聽紀錄為選用項目。在下方探索外掛程式,檢視其權限並安裝,然後在這裡選取。 + 探索外掛程式 + 你的來源 + 再試一次 + 更多外掛程式 + 找不到新的外掛程式。你也可以從上方透過連結或 .smplug 檔案安裝。 + 目前無法探索外掛程式。請再試一次,或透過連結或檔案安裝。 + 歡迎 + 支持 + 外掛程式 + 指南 + + 如何使用 Spotube + 只要四個簡單步驟,就能開始聆聽。 + 登入 + 連結你的 Spotify 帳戶,或選擇你自己的目錄外掛程式。 + 搜尋並播放 + 找到任何歌曲、專輯或藝人,然後按下播放。 + 離線聆聽 + 下載曲目,沒有網路也能繼續播放。 + 跟著歌詞唱 + 播放曲目時,從播放器開啟同步歌詞。 + + + 你的音樂,你的來源,你的規則。 + 一款免費、開放原始碼的音樂播放器,由熱愛音樂的社群打造。 + 開放原始碼 + 無廣告 + 無遙測 + 外掛程式驅動 + 出發吧 + + + 支持者讓我們走下去 + 以熱情打造,因你而延續。 + Spotube 百分之百免費、自由且開放原始碼 — 沒有廣告、沒有訂閱、沒有追蹤。每一項功能的存在,都是因為像你這樣的人選擇支持程式碼持續前進。 + Patreon + 每月贊助 + 支持持續的開發與基礎設施 + Buy Me a Coffee + 請喝一杯咖啡 + 小小打賞,為深夜寫程式加點油 + Open Collective + 社群基金 + 透明、由社群治理的資金支持 + 衷心感謝你相信開放的音樂。 + 支持完全是自願的 — 無論如何,Spotube 都會保持免費。 + 贊助 + 開啟 %1$s + + + 支持 Spotube + 支持 Spotube + Spotube 免費、自由且開放原始碼 — 沒有廣告、沒有訂閱、沒有追蹤。如果它為你帶來快樂,歡迎考慮支持這個專案。 + 你可以在「設定 → 支持」中關閉這些提醒。 + 稍後再說 + 支持提醒 + 每 7 天顯示一次溫和的提醒,邀請你支持這個專案。 + + + 模組化架構 + 選擇你的音樂來源。 + Spotube 將目錄探索、音訊串流與同步歌詞分開處理。在下方設定你的引擎,或隨時切換。 + 中繼資料引擎 + 負責音樂探索、搜尋、曲目清單與播放清單。 + 音訊引擎 + 取得並解碼要播放的音訊串流。 + 歌詞引擎 + 即時串流與時間同步的卡拉 OK 歌詞。 + 收聽紀錄引擎 + 將收聽紀錄傳送至 Last.fm 或 ListenBrainz。 + 使用中 + 內建 + 就緒 + 選用 + 選擇引擎 + 已安裝的引擎 + 設為預設 + NewPipe YouTube 與 LRCLib Lyrics 皆為內建,安裝後即可直接串流。 + 隨時可在「設定 → 外掛程式」中管理或新增社群外掛程式。 + + + 復古摩登架構 + 純粹 Hi-Fi,沒有中間人 + 以下說明 Spotube 如何直接在你的裝置上協調去中心化的播放。 + 直接點對點 • 256 KBPS OPUS + 藍調時刻 + Spotube · 播放器示範 + 為片刻之間的時光,送上一點音樂。 + 1. 去中心化探索 + 查詢會直接送到你選擇的目錄外掛程式。Spotube 絕不分析你的喜好。 + 2. 直接解析串流 + 音訊直接從內容伺服器串流到你的裝置。沒有代理轉送,也沒有遠端資料蒐集。 + 3. 本機 DSP 與播放 + 在本機解碼並快取,提供等化器控制及選用的離線下載。 + 4. 同步歌詞與 Jam + 卡拉 OK 歌詞跟隨播放時間軸,並支援 LAN 遠端控制與群組 Jam。 + 沒有中間伺服器。播放直接在你的裝置上進行 — 隱私從設計開始。 + + + 選擇裝置 + %1$s:「%2$s」 + 播放 + 加入待播清單 + 下一首播放 + 這部裝置 + 在這部裝置上 + 遠端裝置 + 遠端裝置(%1$s) + 在已連線的裝置上 + + + 連線失敗 + 連線中斷 + 傳送失敗 + 搜尋失敗:%1$s + 無法連線:%1$s + + + 裝置 + 重新整理 + 已連線至 %1$s:%2$d + 中斷連線 + 連線中... + 正在搜尋網路上的裝置... + 找不到裝置 + 請確認另一部裝置已在設定中啟用「允許遠端控制」。 + 未知裝置 + 解析中... + ID:%1$s... + + + 遠端控制 + 已中斷連線 + 連線錯誤:%1$s + 專輯封面 + 未知曲目 + 未知藝人 + 播放進度 + 隨機播放 + 上一首 + 下一首 + 重複播放模式 + 音量 + 從待播清單移除 + 更多選項 + + + 要在哪裡播放? + 你有一個進行中的 Jam。請選擇要在哪裡播放這個合輯。 + 在這裡播放 + 推薦到 Jam + + + 尚未設定 Jam 代理伺服器 + 代理伺服器主機為空白 + 連線失敗 + 連線中斷 + + + 代理伺服器 + anonymous + + + 已加入 Jam 待播清單 + + 已將 %1$d 首加入 Jam 待播清單 + + + + 遠端控制要求 + 「%1$s」想要控制這部裝置上的播放。 + 拒絕 + 允許 + 一律允許 + + + 搜尋結果 + 全部 + 曲目 + 使用者 + 沒有可用的搜尋類型 + 輸入文字開始搜尋 + 輸入文字開始搜尋曲目 + 找不到結果 + 查看全部 + 搜尋中 + 正在載入曲目... + 正在載入專輯... + 正在載入藝人... + 正在載入播放清單... + 正在載入使用者... + 找不到專輯 + 找不到藝人 + 找不到播放清單 + 找不到使用者 + 正在翻找唱片箱... + 正在搜尋宇宙... + 正在暖機喇叭... + 正在擦拭黑膠唱片... + 正在請教音樂之神... + 正在調整天線... + 正在翻閱唱片... + 無法載入搜尋功能 + 搜尋失敗 + 無法搜尋曲目 + 無法搜尋專輯 + 無法搜尋藝人 + 無法搜尋播放清單 + 無法搜尋使用者 + + + 瀏覽 + + + 同步 + 純文字 + 沒有可用的同步歌詞 + 沒有可用的純文字歌詞 + + + %1$s(%2$s) + %1$d kbps + %1$s kHz • %2$d 聲道 + 未選取中繼資料外掛程式 + + + 返回 + 重試 + 關閉對話框 + 載入中... + 未知 + 未知錯誤 + + + 使用者 + 播放清單 + + + 我們深感抱歉 + 目前有些功能無法正常運作。你的使用體驗對我們很重要,我們正盡全力修正。請再試一次,或查看下方的錯誤詳細資料。 + 錯誤詳細資料 + 複製到剪貼簿 + 檢視完整記錄 + 完整錯誤記錄 + + + 隨機播放 + 隨機播放 + 追蹤 + 取消追蹤 + 編輯播放清單 + 由 %1$s 建立 + + + 曲目選項 + 加入 Jam + 開始播放電台 + 下一首播放 + 移至下一首 + 從待播清單移除 + 從最愛中移除 + 加入最愛 + 加入播放清單 + 下載 + 分享 + + + 原始順序 + 標題 + 藝人 + 專輯 + 長度 + 排序 + 找不到曲目 + 篩選 + 批次操作 + 全部下載 + 下載 %1$d 首 + 全部加入待播清單 + 將 %1$d 首加入待播清單 + 全部設為下一首播放 + 將 %1$d 首設為下一首播放 + 全部加入播放清單 + 將 %1$d 首加入播放清單 + 全部加入 Jam + 將 %1$d 首加入 Jam + +%1$d 位 + + + 沒有可載入的 URL + 返回上一頁 + 前往下一頁 + + + 篩選封鎖清單... + 沒有已封鎖的項目 + 沒有符合「%1$s」的已封鎖項目 + 曲目 + 曲目 + 加入封鎖清單 + 從封鎖清單移除 + + + 沒有曲目中繼資料 + 無法解析串流 URL + 無法解析串流 URL。請先嘗試播放此曲目。 + + + 已儲存的曲目 + 你 + + + 追蹤中 + 熱門曲目 + 播放熱門曲目 + 將熱門曲目加入待播清單 + 相關藝人 + 精選播放清單 + 無法載入藝人總覽 + 無法取得追蹤者人數 + %1$sK 位追蹤者 + %1$sM 位追蹤者 + %1$sB 位追蹤者 + + %1$d 位追蹤者 + + + + 喜歡 + 取消喜歡 + 無曲目標題 + 無藝人 + 未知專輯 + 播放進度 + 音量 + 開啟隨機播放 + 關閉隨機播放 + 上一首 + 下一首 + 播放或暫停 + 重複播放:關閉 + 重複播放:單曲 + 重複播放:全部 + 靜音 + 取消靜音 + 更多選項 + 播放器選項 + 收合播放器 + 展開歌詞 + 現正播放 + 歌詞 + 替代來源 + 替代來源 + 下載 + 已下載 + 下載中... + 已經下載過了 + 正在重試下載... + 正在下載 %1$s + 播放將在 %1$s 後暫停。 + 在所選的分鐘數後暫停播放。 + 分鐘 + 請選擇 1 到 600 分鐘 + 取消定時器 + 開始 + 播放 URL 是以不同的串流設定解析的。醒目標示的串流符合你目前的偏好;使用中的 URL 會在下次要求串流時更新。 + 配對的音訊來源 + 正在載入配對的來源… + 播放本機快取檔案時,不需要配對的遠端來源。 + 沒有可用的配對來源。 + 此曲目沒有已快取的配對音訊來源。 + 串流詳細資料尚未快取。請先播放此曲目以載入資料。 + 無法載入曲目詳細資料。 + 可用的串流 + 此曲目正從本機快取播放,本次播放不會使用串流變體。 + 正在載入串流… + 此來源沒有可用的串流。 + 目前選取的串流 + 符合你的串流格式與品質偏好 + 使用中的播放 URL + 從本機快取播放 + 中繼資料曲目 + 標題 + ID + 專輯 + 信賴度 + 外部 URI + 專輯 ID + 專輯類型 + 專輯發行日期 + 專輯曲目數 + 專輯類型風格 + 長度 + 曲目編號 + 光碟編號 + 兒少不宜 + 熱門度 + ISRC + 縮圖 URL + + + 切換側邊欄 + + + 替代來源 + 目前沒有播放中的曲目 + 正在搜尋替代來源... + 找不到替代來源 + 信賴度:%1$d% + 使用中的來源 + + + 踢出 + 將對方移出這個 Jam + 踢出並禁止對方再次加入 + 移除推薦曲目 + 移除對方加入待播清單的所有曲目 + 重新排序 + 從待播清單移除 + %1$s • 由 %2$s 加入 + + + 未知 + 正在載入播放清單... + 新增曲目 + 將曲目加入播放清單 + 搜尋曲目... + 搜尋 + 找不到曲目 + 搜尋要加入的曲目 + 完成 + 新增 + 已新增 + + + 正在載入專輯... + + + 加入你的播放清單 + 搜尋 + 搜尋你的播放清單... + 正在載入你的播放清單... + 你還沒有自己的播放清單。\n建立一個來加入曲目吧。 + 建立播放清單 + 編輯播放清單 + 建立 + 名稱 + 播放清單名稱 + 說明 + 說明(選填) + 公開 + 所有人都能看到這個播放清單 + 協作 + 其他人可以將曲目加入這個播放清單 + 封面圖片 + 播放清單封面 + 沒有圖片 + 更換圖片 + 選擇圖片 + + + 安裝 + 取代 + 在 zip 檔案中找不到 plugin.json + 在 zip 檔案中找不到 manifest.zipline.json + plugin.json 格式無效:%1$s + 無法讀取外掛程式:%1$s + 無法安裝外掛程式:%1$s + %1$s 有較新的版本。安裝前請先確認更新。 + %1$s 已經安裝。安裝前請先確認取代。 + 內建外掛程式已包含在內,無法再次新增。 + 無法移除內建外掛程式。 + %1$s 宣告的 apiVersion 無效(%2$s)。應為與 %3$s 相容的語意化版本。 + Spotube 外掛程式 API 版本 %1$s 無效。 + %1$s 的目標為外掛程式 API %2$s,但 Spotube 支援的是不相容變更後的外掛程式 API %3$s。請安裝針對相同主要 API 版本建置的外掛程式。 + + + 外掛程式 API 不相容 + 要安裝外掛程式嗎? + %1$s 將加入你已安裝的外掛程式。 + 要更新外掛程式嗎? + 已安裝舊版(%1$s)。要更新至 %2$s 嗎? + 要取代已安裝的外掛程式嗎? + %1$s 使用了無效的語意化版本(%2$s),無法作為更新進行比較。仍要取代已安裝的外掛程式嗎? + 已安裝的版本(%1$s)無法以語意化版本進行比較。要以 %2$s 取代嗎? + 版本 %1$s 已經安裝。要以提供的版本取代現有的外掛程式嗎? + 已安裝的版本 %1$s 比提供的版本 %2$s 新。仍要取代嗎? + 要以提供的版本取代已安裝的外掛程式嗎? + + + %1$s: + 版本 + API 版本 + 作者 + 授權條款 + 權限 + 功能 + 儲存庫 + 聯絡方式 + 回報錯誤 + 支持 %1$s + + + 官方 + 已驗證 + GitHub 星星數 + GitHub 儲存庫 URL + + + 找不到發行版本 + 選擇發行版本 + 選擇一個發行版本 + 預先發行版 + 版本資訊 + + + 首頁 + 搜尋 + 本機 + 連線 + 裝置 + + + 隱藏視窗 + 顯示視窗 + 下一首 + 上一首 + 隨機播放 + 重複播放 + 重複播放:關閉 + 重複播放:單曲 + 重複播放:全部 + 音量:%1$d% + 音量 + + 音量 - + 靜音 + 取消靜音 + 喜歡此曲目 + 取消喜歡此曲目 + 結束 + + + 最小化 + 最大化 + 還原 + 關閉 + + + 播放服務 + 播放 + 媒體播放控制項 + + + 瀏覽 + 現正播放 + 精選 + 已儲存的曲目 + 熱門曲目 + diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml new file mode 100644 index 00000000..e95e0b45 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -0,0 +1,932 @@ + + + + 播放队列 + 清空队列 + 筛选队列... + 队列为空 + + 搜索歌曲、艺人、专辑... + 清除全部历史记录 + 搜索... + + + 我的音乐库 + 音乐库 + 歌单 + 专辑 + 艺人 + 本地歌曲 + 下载 + 搜索已收藏的歌单… + 搜索已收藏的专辑… + 搜索已关注的艺人… + 搜索文件夹或歌曲… + 筛选音乐库 + 关闭筛选 + 创建歌单 + 刷新本地歌曲 + 切换到网格视图 + 切换到列表视图 + 暂无收藏的歌单 + 没有与“%1$s”匹配的歌单 + 暂无收藏的专辑 + 没有与“%1$s”匹配的专辑 + 暂无关注的艺人 + 没有与“%1$s”匹配的艺人 + + + 文件夹 + 文件夹 + %1$s • %2$s + + %1$d 首歌曲 + + + %1$d 个文件夹 + + 返回文件夹 + 播放文件夹 + 需要音频访问权限 + 允许访问音频,以浏览设备上的音乐。 + 授予权限 + 未找到本地文件夹 + 此文件夹中没有歌曲 + 没有匹配的文件夹或歌曲 + 刷新本地媒体失败 + + + %1$d 个进行中 • %2$d 个已完成 + 清除已完成 + 暂无下载 + 正在下载 %1$d% + 排队中 + 已完成 + 已取消 + 失败 + 失败:%1$s + 取消下载 + 重试下载 + 移除下载 + + + 艺人 + 已拉黑 + 喜欢的歌曲 + 你收藏的歌曲 + 未知艺人 + 歌曲标题 + 艺人名称 + + 已选择 + + 设置 + + 插件 + 语言和地区 + 外观 + 播放 + 下载 + 缓存 + 桌面 + 更新 + 支持 + 关于 + + 关闭 + 保存 + 取消 + + 语言 + 应用界面语言。当前:%1$s + 跟随系统 + 国家/地区 + 用于内容和可用性的国家/地区。当前:%1$s + %1$s (%2$s) + + 主题 + 选择 Spotube 如何匹配系统外观。当前:%1$s + 浅色 + 深色 + 跟随系统 + + 材质 + 应用的背景材质。当前:%1$s + 水润 + 冰晶 + 铬金属 + 亚克力 + 玻璃 + + 强调色 + 当前配色:%1$s + 强调色 + 选择一种强调配色,并在浅色和深色主题下预览。 + 浅色 + 深色 + 预览 + 按钮 + 妖精绿 + 电光紫 + 海洋青 + 日落橙 + 玫瑰园 + 午夜蓝 + 金属灰 + + 外观与风格 + 材质和强调色。当前:%1$s · %2$s + 外观与风格 + 先选择一种材质,再选择与之搭配的强调色。每个选项都是精心挑选的组合。 + 材质 + 强调色 + 次要 + + 配色 + Millennium 强调配色。当前:%1$s + 配色 + 选择一种 Millennium 强调配色,并在浅色和深色下预览。 + 配色 + Millennium 配色和强调色。当前:%1$s + 蓝色 + 橙色 + 青柠 + + 自定义下载文件夹 + 当前文件夹:%1$s + 当前文件夹:默认音乐文件夹 + 留空则使用默认音乐文件夹。 + C:\Music\Spotube + 本地媒体文件夹 + 桌面端要扫描的自定义文件夹。已添加:%1$d + 尚未添加自定义文件夹 + 用于本地音乐库的自定义文件夹。 + 管理 + 添加文件夹 + 移除文件夹 + 未添加自定义文件夹。 + 从扫描列表中移除 %1$s + 下载文件夹和音乐缓存文件夹会自动包含在内。 + 你的下载文件夹会自动包含在内。 + 音乐缓存文件夹会自动包含在内。 + 正在保存… + 无法保存文件夹。你的更改仍然保留,请重试。 + + %1$d 个文件夹 + + 下载音乐格式 + 下载时首选的编解码器和封装格式。当前:%1$s + 下载音质 + + 流媒体音乐格式 + 在线播放时首选的编解码器和封装格式。当前:%1$s + 在线播放音质 + 当前:%1$s + 启用音乐缓存 + 将在线播放的音频保存到本地,加快重复播放速度。 + 缓存文件夹 + 当前:%1$s + 当前:默认应用缓存 + 缓存大小上限 + 无限制 + 当前上限:%1$d MB + 设置缓存的最大大小(MB)。输入 0 或留空表示无限制。 + 500 + 大小必须为 0 或正数。 + 启用无尽播放 + 队列播放完毕后,继续播放推荐的歌曲。 + 启用 Connect + 通过 Spotube Connect 功能开放远程播放控制。 + 播放代理服务器端口 + 允许远程控制 + 允许同一网络中的其他设备在连接后控制播放。 + 远程控制设备名称 + 其他设备发现本设备时看到的名称。当前:%1$s + 设置一个易于识别的名称,网络中的其他设备发现此 Spotube 实例时会显示该名称。 + 例如:客厅的 Spotube + 设备主机名 + 播放代理服务器使用的端口。当前:%1$d + 请选择 1 到 65535 之间的端口。 + 14769 + 请输入有效的整数。 + 端口必须在 1 到 65535 之间。 + + 黑名单 + 管理已拉黑的歌曲和艺人 + + 最小化到托盘 + 窗口最小化时,让 Spotube 在系统托盘中继续运行。 + Discord 动态状态 + 在桌面端的 Discord 中显示当前播放状态。 + + 自动检查更新 + 在后台检查 Spotube 新版本。 + + 管理插件 + 管理你的插件 + 默认%1$s插件 + 元数据 + 音频 + 歌词 + 听歌记录 + 未选择插件 + 更改 + 选择 + 已选择 + 清除选择 + 没有可用的插件 + %1$s 插件 + 清除 + + 配置 + 插件管理器 + 请输入 URL + URL 必须以 http:// 或 https:// 开头 + 插件下载失败 + 尚未安装任何插件 + 在上方粘贴下载 URL 或选择文件。 + + 已安装 %1$d 个插件 + + 安装插件 + https://example.com/plugin.smplug + 下载 + 从文件安装 (.smplug) + 已启用 + v%1$s + 内置 + 移除插件 + 登录 + 退出登录 + 从 URL 下载 + 从文件安装 + 安装 + + • %1$s + 请求的权限 + 此插件不需要任何特殊权限。 + 已安装与待安装对比 + 已安装 + 待安装 + API %1$s → %2$s + 持久存储 + 在磁盘上读写数据 + 网络请求 + 通过互联网收发数据 + WebView + 在应用内显示网页内容 + 一起听 + MQTT 代理服务器 + 代理服务器主机 + %1$s:%2$d + 代理服务器端口 + 使用 TLS + 用户名(可选) + 密码(可选) + 客户端 ID 前缀 + 用于识别此 Spotube 实例 + 加密与代理服务器的连接 + 创建房间前,检查当前的代理服务器设置。 + 测试连接 + 正在测试… + 已连接,耗时 %1$d 毫秒 + 失败:%1$s + 占位代理服务器 — 如需自托管,请配置你自己的服务器 + 连接 + 凭据 + 身份 + 诊断 + + + 一起听 + 创建 + 加入 + 创建一个房间,然后把房间码分享给朋友。 + 创建房间 + 加入房间 + 正在连接… + 房间码 + ABC123 + 代理服务器:%1$s + 请先在设置中配置代理服务器,才能创建或加入房间。 + 代理服务器设置 + 已连接 + 正在重新连接… + 复制房间码 + 已复制! + 分享 + 参与者(%1$d) + 房主 + 听众 + 正在等待听众加入… + 移除 + 封禁 + 管理 + 离开房间 + 无法连接 + 关闭 + 创建一起听会话失败 + 加入一起听会话失败 + 请输入由字母和数字组成的六位房间码。 + 用房间码加入我的 Spotube 一起听:%1$s + + 转到专辑 + 睡眠定时器 + 歌曲详情 + 播放 + 暂停 + 当前没有播放内容 + 添加到队列 + + 忽略 + 更新 + Spotube %1$s 现已推出 + 前往 Spotube 下载页面查看此版本的新功能。 + %1$s %2$s 现已推出 + 当前已安装:%1$s + 前往插件页面查看此更新的新功能。 + + 跳过 + 下一步 + 返回 + 开始使用 + Spotube + 音乐,无拘无束。 + 一款开源播放器,带你探索音乐世界。\n由你探索,由你定义。 + 让这个项目延续下去。 + Spotube 由一群热爱它的人打造,而不是一门订阅生意。如果它已成为你日常生活的一部分,一点点支持就能帮助我们给予它应有的用心呵护。\n\n感谢你为独立软件留出一席之地。 + 先安装一个曲库插件来查找歌曲和歌单,再选择一个音频插件来播放它们。歌词和听歌记录是可选的。在下方发现插件,查看其权限并安装,然后在此处选择它。 + 发现插件 + 你的音源 + 重试 + 更多插件 + 未发现新插件。你也可以通过上方的链接或 .smplug 文件安装插件。 + 插件发现功能暂不可用。请重试,或通过链接或文件安装。 + 欢迎 + 支持 + 插件 + 指南 + + 如何使用 Spotube + 只需四个简单步骤,即可开始聆听。 + 登录 + 连接你的 Spotify 帐号,或选择你自己的曲库插件。 + 搜索并播放 + 查找任意歌曲、专辑或艺人,然后点击播放。 + 离线收听 + 下载歌曲,无网络时也能继续播放。 + 跟随歌词 + 播放歌曲时,从播放器中打开同步歌词。 + + + 你的音乐。你的音源。你做主。 + 一款免费、开源的音乐播放器,由一群热爱音乐的社区成员打造。 + 开源 + 无广告 + 无遥测 + 插件驱动 + 出发吧 + + + 支持者让我们走得更远 + 用热情打造,因你而延续。 + Spotube 100% 免费、自由且开源 — 无广告、无订阅、无追踪。每一项功能的存在,都是因为像你这样的人选择帮助代码持续前行。 + Patreon + 每月赞助 + 支持持续的开发和基础设施 + Buy Me a Coffee + 请杯咖啡 + 用一笔小额打赏,为深夜编码加油 + Open Collective + 社区基金 + 透明、由社区管理的资金支持 + 衷心感谢你对开放音乐的信任。 + 支持完全自愿 — 无论如何 Spotube 都将保持免费。 + 赞助 + 打开 %1$s + + + 支持 Spotube + 支持 Spotube + Spotube 免费、自由且开源 — 无广告、无订阅、无追踪。如果它为你带来了快乐,请考虑支持这个项目。 + 你可以在“设置 → 支持”中关闭这些提醒。 + 以后再说 + 支持提醒 + 每 7 天温馨提醒一次支持本项目。 + + + 模块化架构 + 选择你的音乐来源。 + Spotube 将曲库发现、音频流和同步歌词分开处理。在下方配置各个引擎,或随时切换。 + 元数据引擎 + 为音乐发现、搜索、歌曲列表和歌单提供支持。 + 音频引擎 + 获取并解码要播放的音频流。 + 歌词引擎 + 实时传输逐句同步的卡拉 OK 歌词。 + 听歌记录引擎 + 将听歌记录同步到 Last.fm 或 ListenBrainz。 + 已启用 + 内置 + 就绪 + 可选 + 选择一个引擎 + 已安装的引擎 + 设为默认 + NewPipe YouTube 和 LRCLib 歌词已内置,开箱即可使用。 + 随时可以在“设置 → 插件”中管理或添加新的社区插件。 + + + 复古与现代融合的架构 + 纯粹高保真,零中间商 + 看看 Spotube 如何直接在你的设备上协调去中心化播放。 + 直接点对点 • 256 KBPS OPUS + 蓝调时刻 + Spotube · 播放器演示 + 一点音乐,献给那些间隙时光。 + 1. 去中心化发现 + 查询直接发送到你选择的曲库插件。Spotube 绝不分析你的喜好。 + 2. 直接解析音频流 + 音频直接从内容服务器传输到你的设备。没有代理中转,也没有远程数据收集。 + 3. 本地 DSP 与播放 + 在本地解码和缓存,提供均衡器控制和可选的离线下载。 + 4. 同步歌词与一起听 + 卡拉 OK 歌词跟随播放进度,并支持 LAN 远程控制和一起听。 + 没有中间服务器。播放直接在你的设备上进行 — 隐私从设计开始。 + + + 选择设备 + %1$s:“%2$s” + 播放 + 添加到队列 + 下一首播放 + 本设备 + 在本设备上 + 远程设备 + 远程设备(%1$s) + 在已连接的设备上 + + + 连接失败 + 连接已断开 + 发送失败 + 发现设备失败:%1$s + 连接失败:%1$s + + + 设备 + 刷新 + 已连接到 %1$s:%2$d + 断开连接 + 正在连接... + 正在搜索网络中的设备... + 未找到设备 + 请确保另一台设备已在设置中启用“允许远程控制”。 + 未知设备 + 正在解析... + ID:%1$s... + + + 远程控制 + 已断开连接 + 连接错误:%1$s + 专辑封面 + 未知歌曲 + 未知艺人 + 进度 + 随机播放 + 上一首 + 下一首 + 循环模式 + 音量 + 从队列中移除 + 更多选项 + + + 在哪里播放? + 你正在进行一起听会话。请选择在哪里播放此合集。 + 在此播放 + 推荐到一起听 + + + 未配置一起听代理服务器 + 代理服务器主机为空 + 连接失败 + 连接已断开 + + + 代理服务器 + anonymous + + + 已添加到一起听队列 + + 已将 %1$d 首歌曲添加到一起听队列 + + + + 远程控制请求 + “%1$s”请求控制此设备上的播放。 + 拒绝 + 允许 + 始终允许 + + + 搜索结果 + 全部 + 歌曲 + 用户 + 没有可用的搜索类型 + 输入内容开始搜索 + 输入内容开始搜索歌曲 + 未找到结果 + 查看全部 + 正在搜索 + 正在加载歌曲... + 正在加载专辑... + 正在加载艺人... + 正在加载歌单... + 正在加载用户... + 未找到专辑 + 未找到艺人 + 未找到歌单 + 未找到用户 + 正在翻箱倒柜找唱片... + 正在探索音乐宇宙... + 正在预热音箱... + 正在擦拭黑胶唱片... + 正在请教音乐之神... + 正在调试天线... + 正在翻阅唱片... + 加载搜索功能失败 + 搜索失败 + 搜索歌曲失败 + 搜索专辑失败 + 搜索艺人失败 + 搜索歌单失败 + 搜索用户失败 + + + 浏览 + + + 同步 + 纯文本 + 暂无同步歌词 + 暂无纯文本歌词 + + + %1$s(%2$s 封装) + %1$d kbps + %1$s kHz • %2$d 声道 + 未选择元数据插件 + + + 返回 + 重试 + 关闭对话框 + 正在加载... + 未知 + 未知错误 + + + 用户 + 歌单 + + + 非常抱歉 + 出了点问题。你的体验对我们很重要,我们正在全力修复。请重试,或查看下方的错误详情。 + 错误详情 + 复制到剪贴板 + 查看完整日志 + 完整错误日志 + + + 随机播放 + 随机播放 + 关注 + 取消关注 + 编辑歌单 + 创建者:%1$s + + + 歌曲选项 + 添加到一起听 + 开启电台 + 下一首播放 + 移到下一首 + 从队列中移除 + 从收藏中移除 + 收藏 + 添加到歌单 + 下载 + 分享 + + + 默认 + 标题 + 艺人 + 专辑 + 时长 + 排序 + 未找到歌曲 + 筛选 + 批量操作 + 全部下载 + 下载 %1$d 首 + 全部添加到队列 + 将 %1$d 首添加到队列 + 全部下一首播放 + 下一首播放 %1$d 首 + 全部添加到歌单 + 将 %1$d 首添加到歌单 + 全部添加到一起听 + 将 %1$d 首添加到一起听 + 另外 %1$d 位 + + + 没有要加载的 URL + 在浏览历史中后退 + 在浏览历史中前进 + + + 筛选黑名单... + 黑名单为空 + 没有与“%1$s”匹配的黑名单项目 + 歌曲 + 歌曲 + 加入黑名单 + 从黑名单中移除 + + + 没有歌曲元数据 + 无法解析音频流 URL + 无法解析音频流 URL。请先尝试播放这首歌曲。 + + + 收藏的歌曲 + 你 + + + 已关注 + 热门歌曲 + 播放热门歌曲 + 将热门歌曲添加到队列 + 相关艺人 + 精选歌单 + 加载艺人概览失败 + 粉丝数不可用 + %1$sK 位粉丝 + %1$sM 位粉丝 + %1$sB 位粉丝 + + %1$d 位粉丝 + + + + 喜欢 + 取消喜欢 + 无歌曲标题 + 无艺人 + 未知专辑 + 进度 + 音量 + 开启随机播放 + 关闭随机播放 + 上一首 + 下一首 + 播放或暂停 + 循环模式:关闭 + 循环模式:单曲循环 + 循环模式:全部循环 + 静音 + 取消静音 + 更多选项 + 播放器选项 + 收起播放器 + 展开歌词 + 正在播放 + 歌词 + 备用音源 + 备用音源 + 下载 + 已下载 + 正在下载... + 已下载过 + 正在重试下载... + 正在下载 %1$s + 将在 %1$s后暂停播放。 + 在选定的分钟数后暂停播放。 + 分钟 + 请选择 1 到 600 分钟 + 取消定时 + 开始 + 播放 URL 是使用不同的流媒体设置解析的。高亮显示的音频流符合你当前的偏好;当前使用的 URL 将在下次请求音频流时刷新。 + 匹配的音源 + 正在加载匹配的音源… + 播放本地缓存文件时,无需匹配远程音源。 + 没有可用的匹配音源。 + 此歌曲没有缓存的匹配音源。 + 音频流详情尚未缓存。请先播放这首歌曲以加载详情。 + 加载歌曲详情失败。 + 可用的音频流 + 此歌曲正从本地缓存播放,本次播放不使用音频流变体。 + 正在加载音频流… + 此音源没有可用的音频流。 + 当前选择的音频流 + 符合你的流媒体格式和音质偏好 + 当前播放 URL + 从本地缓存播放 + 元数据歌曲 + 标题 + ID + 专辑 + 匹配度 + 外部 URI + 专辑 ID + 专辑类型 + 专辑发行日期 + 专辑歌曲数 + 专辑流派 + 时长 + 曲目编号 + 碟片编号 + 含露骨内容 + 热度 + ISRC + 缩略图 URL + + + 切换侧边栏 + + + 备用音源 + 当前没有正在播放的歌曲 + 正在搜索备用音源... + 未找到备用音源 + 匹配度:%1$d% + 当前音源 + + + 踢出 + 将其移出会话 + 踢出并禁止其重新加入 + 移除推荐 + 移除其添加到队列中的所有歌曲 + 调整顺序 + 从队列中移除 + %1$s • 由 %2$s 添加 + + + 未知 + 正在加载歌单... + 添加歌曲 + 添加歌曲到歌单 + 搜索歌曲... + 搜索 + 未找到歌曲 + 搜索要添加的歌曲 + 完成 + 添加 + 已添加 + + + 正在加载专辑... + + + 添加到你的歌单 + 搜索 + 搜索你的歌单... + 正在加载你的歌单... + 你还没有创建任何歌单。\n创建一个歌单来添加歌曲吧。 + 创建歌单 + 编辑歌单 + 创建 + 名称 + 歌单名称 + 简介 + 简介(可选) + 公开 + 任何人都可以看到此歌单 + 协作 + 其他人可以向此歌单添加歌曲 + 封面图片 + 歌单封面 + 无图片 + 更换图片 + 选择图片 + + + 安装 + 替换 + 在 zip 文件中未找到 plugin.json + 在 zip 文件中未找到 manifest.zipline.json + plugin.json 格式无效:%1$s + 读取插件失败:%1$s + 安装插件失败:%1$s + %1$s 有新版本可用。请在安装前确认更新。 + %1$s 已安装。请在安装前确认替换。 + 内置插件已包含在应用中,无法再次添加。 + 内置插件无法移除。 + %1$s 声明了无效的 apiVersion(%2$s)。应为与 %3$s 兼容的语义化版本。 + Spotube 插件 API 版本 %1$s 无效。 + %1$s 面向插件 API %2$s,但 Spotube 支持的是存在破坏性变更的插件 API %3$s。请安装针对相同主版本 API 构建的插件。 + + + 插件 API 不兼容 + 安装插件? + %1$s 将被添加到你已安装的插件中。 + 更新插件? + 已安装旧版本(%1$s)。要更新到 %2$s 吗? + 替换已安装的插件? + %1$s 使用了无效的语义化版本(%2$s),无法作为更新进行比较。仍要替换已安装的插件吗? + 已安装的版本(%1$s)无法按语义化版本进行比较。要将其替换为 %2$s 吗? + 版本 %1$s 已安装。要用提供的副本替换现有插件吗? + 已安装的版本 %1$s 比提供的版本 %2$s 更新。仍要替换吗? + 要用提供的副本替换已安装的插件吗? + + + %1$s: + 版本 + API 版本 + 作者 + 许可证 + 权限 + 功能 + 代码仓库 + 联系方式 + 报告问题 + 支持 %1$s + + + 官方 + 已验证 + GitHub 星标 + GitHub 仓库 URL + + + 未找到发布版本 + 选择版本 + 选择一个发布版本 + 预发布版 + 更新说明 + + + 首页 + 搜索 + 本机 + 连接 + 设备 + + + 隐藏窗口 + 显示窗口 + 下一首 + 上一首 + 随机播放 + 循环 + 循环:关闭 + 循环:单曲 + 循环:全部 + 音量:%1$d% + 音量 + + 音量 - + 静音 + 取消静音 + 喜欢这首歌 + 取消喜欢这首歌 + 退出 + + + 最小化 + 最大化 + 还原 + 关闭 + + + 播放服务 + 播放 + 媒体播放控制 + + + 浏览 + 正在播放 + 精选 + 收藏的歌曲 + 热门歌曲 + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6de1648b..62a749a7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -103,19 +103,20 @@ Language and Region Appearance Playback - Downloadsf + Downloads Caching Desktop Updates Support About - Close - Save - Cancel + Close + Save + Cancel Language Language for the app interface. Current: %1$s + System default Country Regional country for content and availability. Current: %1$s %1$s (%2$s) @@ -262,9 +263,10 @@ Failed to download plugin No plugins installed yet Paste a download URL or pick a file above. - %1$d %2$s installed - plugin - plugins + + %1$d plugin installed + %1$d plugins installed + Install a Plugin https://example.com/plugin.smplug Download @@ -470,4 +472,467 @@ 4. Synced Lyrics & Jam Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam. No middleman servers. Playback happens right on your device — private by design. + + + Choose a device + %1$s: \"%2$s\" + Play + Add to queue + Play next + This Device + On this device + Remote Device + Remote Device (%1$s) + On the connected device + + + Connection failed + Connection lost + Send failed + Discovery failed: %1$s + Failed to connect: %1$s + + + Devices + Refresh + Connected to %1$s:%2$d + Disconnect + Connecting... + Searching for devices on the network... + No devices found + Make sure the other device has \"Allow remote control\" enabled in settings. + Unknown Device + Resolving... + ID: %1$s... + + + Remote Control + Disconnected + Connection error: %1$s + Album cover + Unknown Track + Unknown Artist + Seek + Shuffle + Previous + Next + Loop mode + Volume + Remove from queue + More options + + + Play Where? + You have an active jam session. Choose where to play this collection. + Play here + Suggest to Jam + + + No jam broker configured + Broker host is empty + Connection failed + Connection lost + + + Broker + anonymous + + + Added to the jam queue + + Added %1$d to the jam queue + Added %1$d to the jam queue + + + + Remote Control Request + \"%1$s\" wants to control playback on this device. + Deny + Allow + Allow Always + + + Search results + All + Tracks + Users + No search types available + Start typing to search + Start typing to search tracks + No results found + See all + Searching + Loading tracks... + Loading albums... + Loading artists... + Loading playlists... + Loading users... + No albums found + No artists found + No playlists found + No users found + Digging through the crates... + Searching the cosmos... + Warming up the speakers... + Dusting off the vinyl... + Consulting the music gods... + Tuning the antennas... + Flipping through the records... + Failed to load search capabilities + Failed to search + Failed to search tracks + Failed to search albums + Failed to search artists + Failed to search playlists + Failed to search users + + + Browse + + + Synced + Plain + No synced lyrics available + No plain lyrics available + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d ch + No metadata plugin selected + + + Back + Retry + Close dialog + Loading... + Unknown + Unknown error + + + User + Playlist + + + We\'re Truly Sorry + Something isn\'t working right now. Your experience matters to us, and we\'re doing our best to fix this. Please try again or check the error details below. + Error Details + Copy to clipboard + View full log + Full Error Log + + + Shuffle + Shuffle play + Follow + Unfollow + Edit playlist + By %1$s + + + Track options + Add to Jam + Start radio + Play next + Move to next + Remove from queue + Remove from favorites + Save as favorite + Add to playlist + Download + Share + + + Original + Title + Artist + Album + Duration + Sort + No tracks found + Filter + Bulk actions + Download All + Download %1$d + Add All to Queue + Add %1$d to Queue + Play All Next + Play %1$d Next + Add All to Playlist + Add %1$d to Playlist + Add All to Jam + Add %1$d to Jam + +%1$d more + + + No URL to load + Go back to browser history + Go forward to browser history + + + Filter blacklist... + No blacklisted items + No blacklisted items match \'%1$s\' + Tracks + Track + Add to blacklist + Remove from blacklist + + + No track metadata + Could not resolve stream URL + Could not resolve stream URL. Try playing the track first. + + + Saved Tracks + You + + + Following + Top Tracks + Play top tracks + Add top tracks to queue + Related Artists + Featured Playlists + Failed to load artist overview + Followers unavailable + %1$sK followers + %1$sM followers + %1$sB followers + + %1$d follower + %1$d followers + + + + Like + Unlike + No-op track title + No-op artists + Unknown Album + Seek + Volume + Enable shuffle + Disable shuffle + Previous + Next + Play or pause + Loop mode off + Loop mode one + Loop mode all + Mute + Unmute + More options + Player options + Collapse player + Expand lyrics + Now Playing + Lyrics + Alternative Source + Alternative source + Download + Downloaded + Downloading... + Already downloaded + Retrying download... + Downloading %1$s + Playback will pause in %1$s. + Pause playback after the selected number of minutes. + Minutes + Choose between 1 and 600 minutes + Cancel timer + Start + The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request. + Matched audio source + Loading matched source… + A matched remote source is not needed while playing the local cached file. + No matched source is available. + No matched audio source is cached for this track. + Stream details are not cached yet. Play the track first to load them. + Failed to load track details. + Available streams + This track is being served from the local cache. Stream variants are not used for this playback. + Loading streams… + No streams are available for this source. + Currently selected stream + Matches your streaming format & quality preference + Active playback URL + Served from local cache + Metadata track + Title + ID + Album + Confidence + External URI + Album ID + Album type + Album release date + Album track count + Album genres + Duration + Track number + Disc number + Explicit + Popularity + ISRC + Thumbnail URLs + + + Toggle Sidebar + + + Alternative Sources + No track currently playing + Searching for alternative sources... + No alternative sources found + Confidence: %1$d% + Active source + + + Kick + Remove them from the session + Kick and prevent them from rejoining + Remove suggestions + Remove every track they added to the queue + Reorder + Remove from queue + %1$s • Added by %2$s + + + Unknown + Loading playlist... + Add Tracks + Add Tracks to Playlist + Search tracks... + Search + No tracks found + Search for tracks to add + Done + Add + Added + + + Loading album... + + + Add to Your Playlist + Search + Search your playlists... + Loading your playlists... + You don\'t own any playlists yet.\nCreate one to add tracks. + Create Playlist + Edit Playlist + Create + Name + Playlist name + Description + Description (optional) + Public + Anyone can see this playlist + Collaborative + Others can add tracks to this playlist + Cover Image + Playlist cover + No image + Change Image + Pick Image + + + Install + Replace + plugin.json not found in the zip file + manifest.zipline.json not found in the zip file + Invalid plugin.json format: %1$s + Failed to read plugin: %1$s + Failed to install plugin: %1$s + A newer version of %1$s is available. Confirm the update before installing. + %1$s is already installed. Confirm replacing it before installing. + Built-in plugins are already included and can\'t be added again. + Built-in plugins can\'t be removed. + %1$s declares an invalid apiVersion (%2$s). Expected semantic versioning compatible with %3$s. + Spotube plugin API version %1$s is invalid. + %1$s targets plugin API %2$s, but Spotube supports breaking plugin API %3$s. Install a plugin built for the same breaking API version. + + + Plugin API not compatible + Install plugin? + %1$s will be added to your installed plugins. + Update plugin? + An older version is installed (%1$s). Update to %2$s? + Replace installed plugin? + %1$s uses an invalid semantic version (%2$s) and can\'t be compared as an update. Replace the installed plugin anyway? + The installed version (%1$s) can\'t be compared using semantic versioning. Replace it with %2$s? + Version %1$s is already installed. Replace the existing plugin with the supplied copy? + Installed version %1$s is newer than the supplied version %2$s. Replace it anyway? + Replace the installed plugin with the supplied copy? + + + %1$s: + Version + API Version + Author + License + Capabilities + Abilities + Repository + Contact + Report Bugs + Support %1$s + + + Official + Verified + GitHub stars + GitHub repository URL + + + No releases found + Select Release + Select a release + Pre-release + Release Notes + + + Home + Search + On Device + Connect + Devices + + + Hide Window + Show Window + Next Track + Previous Track + Shuffle + Loop + Loop: OFF + Loop: ONE + Loop: ALL + Volume: %1$d% + Volume + + Volume - + Mute + Unmute + Like Track + Unlike Track + Exit + + + Minimize + Maximize + Restore + Close + + + Playback service + Playback + Media playback controls + + + Browse + Now Playing + Featured + Saved Tracks + Top Tracks diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index fb59f5ec..bcb8ba2e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay import dev.krtirtho.spotube.core.DebugConfig +import dev.krtirtho.spotube.core.i18n.AppLocaleProvider import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES @@ -73,48 +74,63 @@ import kotlin.time.Clock import org.koin.compose.koinInject import org.koin.compose.navigation3.koinEntryProvider import org.koin.core.annotation.KoinExperimentalAPI +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_screen_title +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_downloads +import spotube.composeapp.generated.resources.library_tab_local_tracks +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.library_toolbar_compact_title +import spotube.composeapp.generated.resources.nav_devices +import spotube.composeapp.generated.resources.nav_group_connect +import spotube.composeapp.generated.resources.nav_group_on_device +import spotube.composeapp.generated.resources.nav_home +import spotube.composeapp.generated.resources.nav_search +import spotube.composeapp.generated.resources.settings_screen_title interface NavigationItem { data class Tab( - val title: String, val icon: ImageVector, val route: Routes, + val title: StringResource, val icon: ImageVector, val route: Routes, val data: T? = null ) : NavigationItem data class Group( - val title: String, val items: List> + val title: StringResource, val items: List> ) : NavigationItem } private val tabs = listOf( - NavigationItem.Tab("Home", Iconsax.IconsaxHome, Routes.Home), - NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search) + NavigationItem.Tab(Res.string.nav_home, Iconsax.IconsaxHome, Routes.Home), + NavigationItem.Tab(Res.string.nav_search, Iconsax.IconsaxSearch, Routes.Search) ) val sidebarTabs = tabs + listOf( NavigationItem.Group( - "Library", + Res.string.library_toolbar_compact_title, items = listOf( NavigationItem.Tab( - "Playlists", + Res.string.library_tab_playlists, Iconsax.IconsaxMusicDashboard, Routes.Library, LibraryTab.Playlists ), - NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), - NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists) + NavigationItem.Tab(Res.string.library_tab_albums, Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), + NavigationItem.Tab(Res.string.library_tab_artists, Iconsax.User, Routes.Library, LibraryTab.Artists) ) ), NavigationItem.Group( - "On Device", + Res.string.nav_group_on_device, items = listOf( NavigationItem.Tab( - "Local Tracks", + Res.string.library_tab_local_tracks, Iconsax.IconsaxMusicLibrary, Routes.Library, LibraryTab.LocalTracks ), NavigationItem.Tab( - "Downloads", + Res.string.library_tab_downloads, Iconsax.IconsaxDirectboxReceive, Routes.Library, LibraryTab.Downloads @@ -122,16 +138,16 @@ val sidebarTabs = tabs + listOf( ) ), NavigationItem.Group( - "Connect", + Res.string.nav_group_connect, items = listOf( - NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), - NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam) + NavigationItem.Tab(Res.string.nav_devices, Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), + NavigationItem.Tab(Res.string.jam_screen_title, Iconsax.IconsaxSoundTwotone, Routes.Jam) ) ) ) val bottombarTabs = tabs + listOf( - NavigationItem.Tab("Library", Iconsax.IconsaxMusicLibrary, Routes.Library), - NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings) + NavigationItem.Tab(Res.string.library_toolbar_compact_title, Iconsax.IconsaxMusicLibrary, Routes.Library), + NavigationItem.Tab(Res.string.settings_screen_title, Iconsax.IconsaxSetting2, Routes.Settings) ) @OptIn(KoinExperimentalAPI::class) @@ -194,65 +210,68 @@ fun App( Navigator(navigationState) } - SpotubeTheme(settings = userSettings) { - CompositionLocalProvider(LocalSupportPrompt provides { - supportDialogIsReminder = false - showSupportDialog = true - }) { - Box(modifier = Modifier.fillMaxSize()) { - val currentRoute = - navigationState.backStacks[navigationState.topLevelRoute]?.last() + // Below the navigation state, so switching languages keeps the current screen. + AppLocaleProvider(userSettings.language) { + SpotubeTheme(settings = userSettings) { + CompositionLocalProvider(LocalSupportPrompt provides { + supportDialogIsReminder = false + showSupportDialog = true + }) { + Box(modifier = Modifier.fillMaxSize()) { + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() - Box(Modifier.fillMaxSize()) { - AppShell(navigator, navigationState) { - SharedTransitionLayout { - CompositionLocalProvider( - LocalSharedTransitionScope provides this@SharedTransitionLayout - ) { - Column { - NavDisplay( - modifier = Modifier.fillMaxSize(), - onBack = navigator::pop, - entries = navigationState.toEntries(koinEntryProvider()), - transitionSpec = navPushTransitionSpec, - popTransitionSpec = navPopTransitionSpec, - predictivePopTransitionSpec = navPredictivePopTransitionSpec - ) + Box(Modifier.fillMaxSize()) { + AppShell(navigator, navigationState) { + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout + ) { + Column { + NavDisplay( + modifier = Modifier.fillMaxSize(), + onBack = navigator::pop, + entries = navigationState.toEntries(koinEntryProvider()), + transitionSpec = navPushTransitionSpec, + popTransitionSpec = navPopTransitionSpec, + predictivePopTransitionSpec = navPredictivePopTransitionSpec + ) + } } } } - } - when (currentRoute) { - Routes.WebView -> WebViewScreen(koinInject()) - else -> {} + when (currentRoute) { + Routes.WebView -> WebViewScreen(koinInject()) + else -> {} + } } } + content() + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + appUpdateService.dismissUpdate() + }) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser(update.info.directDownloadUrl) } + pluginUpdateService.dismissUpdate() + }) + } + SupportDialog( + visible = showSupportDialog, + onDismissRequest = { showSupportDialog = false }, + isReminder = supportDialogIsReminder) } - content() - availableAppUpdate?.let { update -> - AppUpdateDialog( - update = update, - onDismiss = appUpdateService::dismissUpdate, - onIgnore = appUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } - appUpdateService.dismissUpdate() - }) - } - availablePluginUpdate?.let { update -> - PluginUpdateDialog( - update = update, - onDismiss = pluginUpdateService::dismissUpdate, - onIgnore = pluginUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser(update.info.directDownloadUrl) } - pluginUpdateService.dismissUpdate() - }) - } - SupportDialog( - visible = showSupportDialog, - onDismissRequest = { showSupportDialog = false }, - isReminder = supportDialogIsReminder) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.kt new file mode 100644 index 00000000..60fbd612 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.kt @@ -0,0 +1,60 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.key +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import dev.krtirtho.spotube.modules.settings.SupportedLanguages + +/** + * Points the platform locale (which Compose resources resolve strings from) at [languageTag], + * or back to the system language when it is null, and returns the platform-specific + * composition local that carries it. + */ +@Composable +internal expect fun provideAppLocale(languageTag: String?): ProvidedValue<*> + +/** + * Renders [content] in the app language picked in settings, overriding the system language + * unless [language] is [SupportedLanguages.SYSTEM]. + * + * Compose resources only re-resolve strings when the calling composable recomposes, so the + * content is keyed on the language: switching languages recreates the subtree. Keep state that + * must survive a language switch (e.g. navigation) above this provider. + */ +@Composable +fun AppLocaleProvider(language: SupportedLanguages, content: @Composable () -> Unit) { + val languageTag = language.languageTag + val layoutDirection = when { + language == SupportedLanguages.SYSTEM -> null + language.isRtl -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } + val locales = listOfNotNull( + provideAppLocale(languageTag), + layoutDirection?.let { LocalLayoutDirection provides it }) + CompositionLocalProvider(*locales.toTypedArray()) { + key(languageTag) { + content() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt index c09b4e20..e36b974b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt @@ -44,6 +44,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_error_broker_host_empty +import spotube.composeapp.generated.resources.jam_error_connection_failed +import spotube.composeapp.generated.resources.jam_error_connection_lost +import spotube.composeapp.generated.resources.jam_error_no_broker +import spotube.composeapp.generated.resources.settings_jam_broker_test_ok import org.koin.core.component.KoinComponent import kotlin.time.Duration.Companion.seconds @@ -92,7 +99,7 @@ class JamRoomClient : KoinComponent { /** One-off connection check used by the settings screen. Returns latency description. */ suspend fun testConnection(broker: JamBroker): Result { - if (broker.host.isBlank()) return Result.failure(IllegalArgumentException("Broker host is empty")) + if (broker.host.isBlank()) return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_broker_host_empty))) val started = kotlin.time.TimeSource.Monotonic.markNow() return runCatching { val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test") @@ -100,7 +107,7 @@ class JamRoomClient : KoinComponent { withTimeout(broker.connectionTimeoutSeconds.seconds) { client.awaitConnectivity() } - "Connected in ${started.elapsedNow().inWholeMilliseconds} ms" + getString(Res.string.settings_jam_broker_test_ok, started.elapsedNow().inWholeMilliseconds) } finally { runCatching { client.shutdown(sendDisconnect = true, drain = false) } } @@ -116,7 +123,7 @@ class JamRoomClient : KoinComponent { isHost: Boolean): Result { disconnect() if (broker.host.isBlank()) { - return Result.failure(IllegalArgumentException("No jam broker configured")) + return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_no_broker))) } return runCatching { roomCode = code @@ -140,7 +147,7 @@ class JamRoomClient : KoinComponent { Unit }.onFailure { e -> log.w(e) { "Failed to connect to jam broker" } - _connectionError.value = e.message ?: "Connection failed" + _connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed) _isConnected.value = false runCatching { client?.shutdown(sendDisconnect = true, drain = false) } client = null @@ -245,7 +252,10 @@ class JamRoomClient : KoinComponent { else -> { _isConnected.value = false - _connectionError.value = "Connection lost" + scope.launch { + val message = getString(Res.string.jam_error_connection_lost) + if (!_isConnected.value) _connectionError.value = message + } } } } @@ -309,4 +319,4 @@ class JamRoomClient : KoinComponent { private fun cmdTopic(code: String) = "spotube/jam/$code/cmd" private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId" private fun roomFilter(code: String) = "spotube/jam/$code/#" -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index b9918040..422b7b44 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -40,6 +40,10 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.random.Random +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_error_invalid_code +import spotube.composeapp.generated.resources.jam_error_no_broker import kotlin.time.Clock /** @@ -157,7 +161,7 @@ class JamRoomService( suspend fun createRoom(): Result { val broker = settingsRepository.userSettings.value.jamBroker if (broker.host.isBlank()) { - return Result.failure(IllegalStateException("No jam broker configured")) + return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_broker))) } val code = JamRoomCode.generate() localClientId = newClientId(broker) @@ -188,11 +192,11 @@ class JamRoomService( suspend fun joinRoom(code: String): Result { val broker = settingsRepository.userSettings.value.jamBroker if (broker.host.isBlank()) { - return Result.failure(IllegalStateException("No jam broker configured")) + return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_broker))) } val normalized = JamRoomCode.normalize(code) if (!JamRoomCode.isValid(normalized)) { - return Result.failure(IllegalArgumentException("Invalid room code")) + return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code))) } localClientId = newClientId(broker) val name = participantName("Guest") @@ -558,4 +562,4 @@ class JamRoomService( private const val HOST_TAKEOVER_DELAY_MS = 1_500L private const val AUTO_ADVANCE_WINDOW_MS = 2_000L } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt index 995d66e5..68e1d03b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt @@ -35,7 +35,14 @@ import androidx.compose.ui.unit.dp import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumDialog +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.remote_request_allow +import spotube.composeapp.generated.resources.remote_request_allow_always +import spotube.composeapp.generated.resources.remote_request_deny +import spotube.composeapp.generated.resources.remote_request_message +import spotube.composeapp.generated.resources.remote_request_title @Composable fun ConnectionRequestDialogHost() { @@ -55,9 +62,9 @@ fun ConnectionRequestDialogHost() { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) pendingRequest = null }, - title = "Remote Control Request") { + title = stringResource(Res.string.remote_request_title)) { Text( - text = "\"${request.deviceName}\" wants to control playback on this device.", + text = stringResource(Res.string.remote_request_message, request.deviceName), style = AppTheme.typography.bodyMedium, textAlign = TextAlign.Start) Row( @@ -70,14 +77,14 @@ fun ConnectionRequestDialogHost() { pendingRequest = null }, style = MillenniumButtonStyle.Secondary) { - Text("Deny") + Text(stringResource(Res.string.remote_request_deny)) } MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) pendingRequest = null }) { - Text("Allow") + Text(stringResource(Res.string.remote_request_allow)) } MillenniumButton( onClick = { @@ -85,7 +92,7 @@ fun ConnectionRequestDialogHost() { pendingRequest = null }, style = MillenniumButtonStyle.Quiet) { - Text("Allow Always") + Text(stringResource(Res.string.remote_request_allow_always)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index 5dd7c43b..30bfa3b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -38,6 +38,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_error_connection_failed +import spotube.composeapp.generated.resources.devices_error_connection_lost +import spotube.composeapp.generated.resources.devices_error_send_failed /** * WebSocket client for controlling a remote Spotube instance. @@ -94,7 +99,7 @@ class RemoteControlClient { } } catch (e: Exception) { logger.e(e) { "Failed to connect to $host:$port" } - _connectionState.value = ConnectionState.Error(e.message ?: "Connection failed") + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed) disconnect() } } @@ -150,7 +155,7 @@ class RemoteControlClient { logger.d { "Receive loop exited normally" } } catch (e: Exception) { logger.e(e) { "Error in receive loop" } - _connectionState.value = ConnectionState.Error(e.message ?: "Connection lost") + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost) } } @@ -170,7 +175,7 @@ class RemoteControlClient { logger.d { "Sent command: $command" } } catch (e: Exception) { logger.e(e) { "Failed to send command" } - _connectionState.value = ConnectionState.Error(e.message ?: "Send failed") + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_send_failed) } } @@ -195,5 +200,9 @@ sealed interface ConnectionState { data object Disconnected : ConnectionState data object Connecting : ConnectionState data class Connected(val host: String, val port: Int) : ConnectionState - data class Error(val message: String) : ConnectionState -} \ No newline at end of file + /** + * [message] is the raw (untranslated) cause when available; [fallback] is the + * localized description to show when it isn't. + */ + data class Error(val message: String?, val fallback: StringResource) : ConnectionState +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 3627dff7..936a2855 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -36,6 +36,11 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getPluralString +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_queue_added_track +import spotube.composeapp.generated.resources.jam_queue_added_tracks enum class PlaybackDestinationAction { Play, @@ -179,7 +184,7 @@ class RemotePlaybackController( JamRole.Guest -> jamRoomService.suggestTrack(track) null -> return@launch } - _events.emit("Added to the jam queue") + _events.emit(getString(Res.string.jam_queue_added_track)) } catch (e: Exception) { logger.e(e) { "Failed to add track to jam session" } } @@ -207,7 +212,7 @@ class RemotePlaybackController( JamRole.Guest -> jamRoomService.suggestPlaylist(tracks) null -> return@launch } - _events.emit("Added ${tracks.size} to the jam queue") + _events.emit(getPluralString(Res.plurals.jam_queue_added_tracks, tracks.size, tracks.size)) } catch (e: Exception) { logger.e(e) { "Failed to add tracks to jam session" } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt index 7d3c25a2..24bda126 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -43,6 +43,8 @@ import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun AdaptiveDialogBottomSheet( @@ -92,7 +94,7 @@ fun AdaptiveDialogBottomSheet( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( Iconsax.FluentDismiss, - contentDescription = "Close dialog") + contentDescription = stringResource(Res.string.common_close_dialog)) } } Spacer(Modifier.height(12.dp)) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt index 2b40616e..97d7aa7b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt @@ -28,6 +28,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 import dev.krtirtho.spotube.resources.iconsax.Iconsax import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun ApplicationBackButton() { @@ -40,7 +42,7 @@ fun ApplicationBackButton() { style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.ArrowLeft3, - contentDescription = "Back" + contentDescription = stringResource(Res.string.common_back) ) } } @@ -51,4 +53,4 @@ expect fun ApplicationMainBar( subtitle: @Composable () -> Unit = {}, actions: @Composable (RowScope.() -> Unit) = {}, backButton: Boolean = true, - transparent: Boolean = false) \ No newline at end of file + transparent: Boolean = false) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 7356b4b5..07da835f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -66,6 +66,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.User import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -107,8 +109,8 @@ fun CollectionDetails( onClick = onAddToJam!!) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to Jam") - TextWithShimmer(text = "Add to Jam") + contentDescription = stringResource(Res.string.track_options_add_to_jam)) + TextWithShimmer(text = stringResource(Res.string.track_options_add_to_jam)) } } } @@ -127,16 +129,16 @@ fun CollectionDetails( onClick = onPlay) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play") - TextWithShimmer(text = if (isPlaying) "Pause" else "Play") + contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) + TextWithShimmer(text = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) } MillenniumButton( modifier = modifier, onClick = onShufflePlay, style = MillenniumButtonStyle.Secondary ) { - MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer(text = "Shuffle") + MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play)) + TextWithShimmer(text = stringResource(Res.string.collection_details_shuffle)) } } } @@ -149,7 +151,7 @@ fun CollectionDetails( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue" + contentDescription = stringResource(Res.string.player_add_to_queue_content_description) ) } MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) @@ -160,7 +162,7 @@ fun CollectionDetails( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxHeart2, - contentDescription = "Unfollow") + contentDescription = stringResource(Res.string.collection_details_unfollow)) } } else { MillenniumIconButton( @@ -168,7 +170,7 @@ fun CollectionDetails( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxHeart, - contentDescription = "Follow", + contentDescription = stringResource(Res.string.collection_details_follow), tint = AppTheme.colors.onSurfaceVariant) } } @@ -180,7 +182,7 @@ fun CollectionDetails( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxEdit, - contentDescription = "Edit playlist") + contentDescription = stringResource(Res.string.collection_details_edit_playlist)) } } } @@ -213,7 +215,7 @@ fun CollectionDetails( tint = AppTheme.colors.onPrimaryContainer) } TextWithShimmer( - text = "By $ownerName", + text = stringResource(Res.string.collection_details_by_owner, ownerName), style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 1b660b4c..5dbbaa16 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -52,6 +52,8 @@ import org.jetbrains.compose.resources.painterResource import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun CollectionView( @@ -124,7 +126,7 @@ fun CollectionView( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play") + contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) } } } else { @@ -145,11 +147,11 @@ fun CollectionView( if (isLoading && tracks.isEmpty()) { dev.krtirtho.spotube.core.ui.misc.SkeletonTree(true) { CollectionDetails( - title = title.ifBlank { "Loading..." }, + title = title.ifBlank { stringResource(Res.string.common_loading) }, description = description, imageURL = imageURL, imageResource = imageResource, - ownerName = ownerName.ifBlank { "Unknown" }, + ownerName = ownerName.ifBlank { stringResource(Res.string.common_unknown) }, ownerImageURL = ownerImageURL, onOwnerClick = onOwnerClick, onPlay = onPlay, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt index be7fed6b..398cd494 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt @@ -65,6 +65,8 @@ import compose.icons.feathericons.AlertCircle import compose.icons.feathericons.Copy import compose.icons.feathericons.Maximize2 import compose.icons.feathericons.RefreshCw +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun ErrorDisplay( @@ -87,14 +89,14 @@ fun ErrorDisplay( tint = AppTheme.colors.error.copy(alpha = 0.7f)) Text( - text = "We're Truly Sorry", + text = stringResource(Res.string.error_title), style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = AppTheme.colors.onSurface, textAlign = TextAlign.Center) Text( - text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.", + text = stringResource(Res.string.error_description), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, @@ -112,7 +114,7 @@ fun ErrorDisplay( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text( - text = "Error Details", + text = stringResource(Res.string.error_details), style = AppTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = AppTheme.colors.onSurfaceVariant) @@ -127,7 +129,7 @@ fun ErrorDisplay( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = FeatherIcons.Copy, - contentDescription = "Copy to clipboard", + contentDescription = stringResource(Res.string.error_copy_to_clipboard), modifier = Modifier.size(16.dp)) } @@ -137,7 +139,7 @@ fun ErrorDisplay( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = FeatherIcons.Maximize2, - contentDescription = "View full log", + contentDescription = stringResource(Res.string.error_view_full_log), modifier = Modifier.size(16.dp)) } } @@ -170,7 +172,7 @@ fun ErrorDisplay( imageVector = FeatherIcons.RefreshCw, contentDescription = null, modifier = Modifier.padding(end = 8.dp).size(18.dp)) - Text("Retry") + Text(stringResource(Res.string.common_retry)) } } } @@ -198,7 +200,7 @@ private fun ErrorLogDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text( - text = "Full Error Log", + text = stringResource(Res.string.error_full_log_title), style = AppTheme.typography.titleLarge, fontWeight = FontWeight.Bold) @@ -209,7 +211,7 @@ private fun ErrorLogDialog( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = FeatherIcons.Copy, - contentDescription = "Copy to clipboard") + contentDescription = stringResource(Res.string.error_copy_to_clipboard)) } } Box( @@ -236,7 +238,7 @@ private fun ErrorLogDialog( MillenniumButton( onClick = onDismiss, style = MillenniumButtonStyle.Quiet) { - Text("Close") + Text(stringResource(Res.string.common_close)) } } } @@ -260,4 +262,4 @@ private fun ErrorLogDialogPreview() { errorMessage = "This is an error message", onDismiss = {}) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt index 142358fe..4ae55ebc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt @@ -83,6 +83,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle import dev.krtirtho.spotube.resources.iconsax.Play import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun FeaturedCarousel( @@ -166,7 +168,7 @@ private fun FeaturedCarouselCard( val subtitle = when (item) { is MetadataBrowseItem.Album -> item.data.artists.joinToString { it.name } is MetadataBrowseItem.Playlist -> item.data.owner?.displayName ?: item.data.owner?.username - ?: "Playlist" + ?: stringResource(Res.string.media_playlist) is MetadataBrowseItem.Track -> item.data.artists.joinToString { it.name } else -> "" diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index b15a985a..e8cf94de 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -111,17 +111,20 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxSort +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* private val CompactTrackListBreakpoint = 600.dp private val LargeTrackListBreakpoint = 840.dp private const val ShimmerRowCount = 6 -enum class TrackSortOption(val label: String) { - None("Original"), - Title("Title"), - Artist("Artist"), - Album("Album"), - Duration("Duration"), +enum class TrackSortOption(val label: StringResource) { + None(Res.string.track_list_sort_original), + Title(Res.string.track_list_sort_title), + Artist(Res.string.track_list_sort_artist), + Album(Res.string.track_list_sort_album), + Duration(Res.string.track_list_sort_duration), } @OptIn(ExperimentalFoundationApi::class) @@ -311,7 +314,7 @@ fun TrackList( } if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { - TrackListFeedbackRow("No tracks found") + TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks)) } if (isLoadingNextPage) { @@ -365,7 +368,7 @@ fun TrackList( MillenniumTextField( state = filterState, label = "", - placeholder = "Filter", + placeholder = stringResource(Res.string.track_list_filter_placeholder), modifier = Modifier .widthIn(max = 400.dp) .align(Alignment.CenterEnd)) @@ -377,7 +380,7 @@ fun TrackList( AdaptiveDropdownBottomSheet( items = TrackSortOption.entries.map { option -> AdaptiveMenuItem( - label = option.label, + label = stringResource(option.label), onClick = { sortBy = option }, selected = sortBy == option) }, @@ -387,7 +390,7 @@ fun TrackList( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxSort, - contentDescription = "Sort") + contentDescription = stringResource(Res.string.track_list_sort)) } }) } @@ -407,30 +410,30 @@ fun TrackList( add( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, - label = if (isAll) "Download All" else "Download $trackCount", + label = if (isAll) stringResource(Res.string.track_list_download_all) else stringResource(Res.string.track_list_download_count, trackCount), onClick = { onBulkDownload(targetTracks) })) if (!isJamGuest) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, - label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", + label = if (isAll) stringResource(Res.string.track_list_add_all_to_queue) else stringResource(Res.string.track_list_add_count_to_queue, trackCount), onClick = { onBulkAddToQueue(targetTracks) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, - label = if (isAll) "Play All Next" else "Play $trackCount Next", + label = if (isAll) stringResource(Res.string.track_list_play_all_next) else stringResource(Res.string.track_list_play_count_next, trackCount), onClick = { onBulkPlayNext(targetTracks) })) } add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicPlaylist, - label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", + label = if (isAll) stringResource(Res.string.track_list_add_all_to_playlist) else stringResource(Res.string.track_list_add_count_to_playlist, trackCount), onClick = { onBulkAddToPlaylist(targetTracks) })) } + if (isInJam) { listOf( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, - label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", + label = if (isAll) stringResource(Res.string.track_list_add_all_to_jam) else stringResource(Res.string.track_list_add_count_to_jam, trackCount), onClick = { onBulkAddToJam(targetTracks) })) } else { emptyList() @@ -441,7 +444,7 @@ fun TrackList( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Bulk actions") + contentDescription = stringResource(Res.string.track_list_bulk_actions)) } }) } @@ -644,7 +647,7 @@ private fun TrackListRow( contentAlignment = Alignment.Center) { MillenniumIcon( imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play", + contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description), tint = Color.White, modifier = Modifier.size(18.dp).shimmerApply()) } @@ -713,7 +716,7 @@ private fun TrackListRow( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Track options") + contentDescription = stringResource(Res.string.track_options_title)) } } } @@ -762,7 +765,7 @@ private fun ArtistChips( val remaining = artists.size - visibleArtists.size if (remaining > 0) { TextWithShimmer( - text = "+$remaining more", + text = stringResource(Res.string.track_list_more_artists, remaining), style = AppTheme.typography.bodySmall, color = AppTheme.colors.primary, maxLines = 1, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index 0f4653b8..e17d0d66 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -58,6 +58,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxShare +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* sealed interface TrackOptionsAction { data object AddToJam : TrackOptionsAction @@ -116,7 +118,7 @@ fun TrackOptions( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Track options", + contentDescription = stringResource(Res.string.track_options_title), modifier = Modifier.shimmerApply() ) } @@ -238,6 +240,7 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) { } } +@Composable private fun buildTrackMenuItems( track: MetadataTrack, state: TrackOptionsState, @@ -249,14 +252,14 @@ private fun buildTrackMenuItems( add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, - label = "Add to Jam", + label = stringResource(Res.string.track_options_add_to_jam), onClick = { onAction(TrackOptionsAction.AddToJam) })) } add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicCircle, - label = "Start radio", + label = stringResource(Res.string.track_options_start_radio), onClick = { onAction(TrackOptionsAction.StartRadio) })) // A guest's queue is the shared jam queue — mutating it locally is not @@ -266,13 +269,13 @@ private fun buildTrackMenuItems( add( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, - label = "Play next", + label = stringResource(Res.string.track_options_play_next), onClick = { onAction(TrackOptionsAction.PlayNext) })) } else if (state.isInQueue && !state.isCurrentlyPlaying) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, - label = "Move to next", + label = stringResource(Res.string.track_options_move_to_next), onClick = { onAction(TrackOptionsAction.PlayNext) })) } @@ -280,13 +283,13 @@ private fun buildTrackMenuItems( add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, - label = "Add to queue", + label = stringResource(Res.string.player_add_to_queue_content_description), onClick = { onAction(TrackOptionsAction.AddToQueue) })) } else { add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicSquareRemove, - label = "Remove from queue", + label = stringResource(Res.string.track_options_remove_from_queue), onClick = { onAction(TrackOptionsAction.RemoveFromQueue) })) } } @@ -294,32 +297,32 @@ private fun buildTrackMenuItems( add( AdaptiveMenuItem( icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, - label = if (state.isFavorite) "Remove from favorites" else "Save as favorite", + label = if (state.isFavorite) stringResource(Res.string.track_options_remove_from_favorites) else stringResource(Res.string.track_options_save_as_favorite), onClick = { onAction(TrackOptionsAction.ToggleFavorite) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicPlaylist, - label = "Add to playlist", + label = stringResource(Res.string.track_options_add_to_playlist), onClick = { onAction(TrackOptionsAction.AddToPlaylist) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, - label = "Download", + label = stringResource(Res.string.track_options_download), onClick = { onAction(TrackOptionsAction.Download) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicSquareRemoveFilled, - label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", + label = if (state.isBlacklisted) stringResource(Res.string.blacklist_remove) else stringResource(Res.string.blacklist_add), onClick = { onAction(TrackOptionsAction.ToggleBlacklist) })) if (!track.externalUri.isNullOrBlank()) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxShare, - label = "Share", + label = stringResource(Res.string.track_options_share), onClick = { onAction(TrackOptionsAction.Share) })) } @@ -327,7 +330,7 @@ private fun buildTrackMenuItems( add( AdaptiveMenuItem( icon = Iconsax.IconsaxCd, - label = "Go to album", + label = stringResource(Res.string.player_go_to_album), onClick = onAlbumClick)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt index 868ec005..c42a5e34 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun UserCard( @@ -47,7 +49,7 @@ fun UserCard( modifier: Modifier = Modifier) { AvatarCard( title = user.displayName ?: user.username, - subtitle = "User", + subtitle = stringResource(Res.string.media_user), imageURL = user.thumbnails.firstOrNull()?.url, onClick = { // TODO: Route to user details screen. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index 583ba6e2..0b0596af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -63,6 +63,8 @@ import dev.nucleusframework.webview.web.WebView import dev.nucleusframework.webview.web.WebViewNavigator import dev.nucleusframework.webview.web.WebViewState import dev.nucleusframework.webview.web.rememberWebViewNavigator +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* class PostMessageHandler( private val onMessageReceived: (String) -> Unit = {} @@ -83,7 +85,7 @@ class PostMessageHandler( fun PlatformWebViewScreen(webViewController: WebViewController) { if (webViewController.getContent() == null) { // This should never happen, but just in case - Text("No URL to load") + Text(stringResource(Res.string.webview_no_url)) return } @@ -173,7 +175,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) { ) { MillenniumIcon( FeatherIcons.ChevronLeft, - contentDescription = "Go back to browser history" + contentDescription = stringResource(Res.string.webview_go_back) ) } MillenniumIconButton( @@ -185,7 +187,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) { ) { MillenniumIcon( FeatherIcons.ChevronRight, - contentDescription = "Go forward to browser history" + contentDescription = stringResource(Res.string.webview_go_forward) ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index ed4b9a11..3aa949b8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -31,6 +31,10 @@ import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.album_loading_title +import spotube.composeapp.generated.resources.media_unknown_artist @Composable fun AlbumScreen( @@ -56,7 +60,7 @@ fun AlbumScreen( val dataState = state as? AlbumScreenState.Data val album = dataState?.album val ownerName = - album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" } + album?.artists?.joinToString { it.name }.orEmpty().ifBlank { stringResource(Res.string.media_unknown_artist) } val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url val firstArtistId = album?.artists?.firstOrNull()?.id val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty() @@ -67,7 +71,7 @@ fun AlbumScreen( val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( - title = album?.title ?: "Loading album...", + title = album?.title ?: stringResource(Res.string.album_loading_title), description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}", imageURL = artworkUrl, ownerName = ownerName, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index 2cd84136..f1c8476a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -49,6 +49,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface AlbumScreenState { data object Loading : AlbumScreenState @@ -172,7 +175,7 @@ class AlbumViewModel( nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load album" } - _state.value = AlbumScreenState.Error(e.message ?: "Unknown error") + _state.value = AlbumScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreTracks() = runCatching { @@ -190,7 +193,7 @@ class AlbumViewModel( } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } - _state.value = AlbumScreenState.Error(e.message ?: "Unknown error") + _state.value = AlbumScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun loadNextTracksPage() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 51934952..5092a82b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -93,6 +93,9 @@ import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlin.math.roundToInt +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun ArtistScreen( @@ -334,7 +337,7 @@ private fun ArtistErrorContent( style = AppTheme.typography.bodyLarge ) MillenniumButton(onClick = onRetry) { - Text("Retry") + Text(stringResource(Res.string.common_retry)) } } } @@ -509,7 +512,7 @@ private fun ArtistHeaderActions( style = MillenniumButtonStyle.Secondary ) { Text( - "Following", + stringResource(Res.string.artist_following), textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -517,12 +520,12 @@ private fun ArtistHeaderActions( } } else { MillenniumButton(onClick = onFollowClick) { - Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) + Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } MillenniumTooltip( - text = if (isBlacklisted) "Remove from blacklist" else "Add to blacklist" + text = if (isBlacklisted) stringResource(Res.string.blacklist_remove) else stringResource(Res.string.blacklist_add) ) { if (isBlacklisted) { MillenniumIconButton( @@ -531,7 +534,7 @@ private fun ArtistHeaderActions( ) { MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Remove from blacklist", + contentDescription = stringResource(Res.string.blacklist_remove), tint = AppTheme.colors.error ) } @@ -542,7 +545,7 @@ private fun ArtistHeaderActions( ) { MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Add to blacklist" + contentDescription = stringResource(Res.string.blacklist_add) ) } } @@ -563,7 +566,7 @@ private fun TopTracksHeader( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = "Top Tracks", + text = stringResource(Res.string.artist_top_tracks), style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) @@ -575,7 +578,7 @@ private fun TopTracksHeader( ) { MillenniumIcon( imageVector = Iconsax.IconsaxPlay, - contentDescription = "Play top tracks", + contentDescription = stringResource(Res.string.artist_play_top_tracks), tint = LocalTextStyle.current.color ) } @@ -585,7 +588,7 @@ private fun TopTracksHeader( ) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add top tracks to queue" + contentDescription = stringResource(Res.string.artist_add_top_tracks_to_queue) ) } } @@ -615,7 +618,7 @@ private fun AlbumsSection( } } - SectionHeader(title = "Albums") + SectionHeader(title = stringResource(Res.string.library_tab_albums)) Spacer(modifier = Modifier.size(8.dp)) LazyRow( state = rowState, @@ -653,7 +656,7 @@ private fun RelatedArtistsSection( } } - SectionHeader(title = "Related Artists") + SectionHeader(title = stringResource(Res.string.artist_related_artists)) Spacer(modifier = Modifier.size(8.dp)) LazyRow( state = rowState, @@ -691,7 +694,7 @@ private fun FeaturedPlaylistsSection( } } - SectionHeader(title = "Featured Playlists") + SectionHeader(title = stringResource(Res.string.artist_featured_playlists)) Spacer(modifier = Modifier.size(8.dp)) LazyRow( state = rowState, @@ -716,13 +719,14 @@ private fun SectionHeader(title: String) { ) } +@Composable private fun formatFollowers(count: Int?): String { - if (count == null) return "Followers unavailable" + if (count == null) return stringResource(Res.string.artist_followers_unavailable) return when { - count >= 1_000_000_000 -> "${formatAbbreviatedCount(count, 1_000_000_000)}B followers" - count >= 1_000_000 -> "${formatAbbreviatedCount(count, 1_000_000)}M followers" - count >= 1_000 -> "${formatAbbreviatedCount(count, 1_000)}K followers" - else -> "$count followers" + count >= 1_000_000_000 -> stringResource(Res.string.artist_followers_billions, formatAbbreviatedCount(count, 1_000_000_000)) + count >= 1_000_000 -> stringResource(Res.string.artist_followers_millions, formatAbbreviatedCount(count, 1_000_000)) + count >= 1_000 -> stringResource(Res.string.artist_followers_thousands, formatAbbreviatedCount(count, 1_000)) + else -> pluralStringResource(Res.plurals.artist_followers_count, count, count) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index 9fa5ac86..a508f193 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -46,6 +46,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* sealed interface ArtistScreenState { data object Loading : ArtistScreenState @@ -380,11 +382,11 @@ class ArtistViewModel( featuredPlaylists = overview.featuredPlaylists.items, featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination) } else { - _state.value = ArtistScreenState.Error("Failed to load artist overview") + _state.value = ArtistScreenState.Error(getString(Res.string.artist_error_load_failed)) } }.onFailure { e -> logger.e(e) { "Failed to load artist overview" } - _state.value = ArtistScreenState.Error(e.message ?: "Unknown error") + _state.value = ArtistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt index 6ec6972e..6cf3eaca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt @@ -63,6 +63,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove import dev.krtirtho.spotube.resources.iconsax.User import org.koin.compose.viewmodel.koinViewModel +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun BlacklistScreen() { @@ -70,7 +72,7 @@ fun BlacklistScreen() { Column(modifier = Modifier.fillMaxSize()) { ApplicationMainBar( backButton = true, - title = { Text("Blacklist") }) + title = { Text(stringResource(Res.string.settings_blacklist_title)) }) BlacklistContent( viewModel = viewModel, modifier = Modifier.weight(1f)) @@ -130,7 +132,7 @@ internal fun BlacklistContent( state = filterState, label = "", modifier = Modifier.fillMaxWidth(), - placeholder = "Filter blacklist...") + placeholder = stringResource(Res.string.blacklist_filter_placeholder)) if (isEmpty) { Box( @@ -140,9 +142,9 @@ internal fun BlacklistContent( contentAlignment = Alignment.Center) { Text( text = if (hasActiveFilter) { - "No blacklisted items match '$filterQuery'" + stringResource(Res.string.blacklist_no_results, filterQuery) } else { - "No blacklisted items" + stringResource(Res.string.blacklist_empty) }, style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) @@ -156,7 +158,7 @@ internal fun BlacklistContent( if (filteredTracks.isNotEmpty()) { item { Text( - text = "Tracks", + text = stringResource(Res.string.blacklist_section_tracks), style = AppTheme.typography.titleSmall, color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp)) @@ -171,7 +173,7 @@ internal fun BlacklistContent( if (filteredArtists.isNotEmpty()) { item { Text( - text = "Artists", + text = stringResource(Res.string.library_tab_artists), style = AppTheme.typography.titleSmall, color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp)) @@ -199,7 +201,7 @@ private fun BlacklistedTrackRow( horizontalArrangement = Arrangement.spacedBy(12.dp)) { MillenniumIcon( imageVector = Iconsax.IconsaxMusicCircle, - contentDescription = "Track", + contentDescription = stringResource(Res.string.blacklist_item_track), modifier = Modifier.size(20.dp), tint = AppTheme.colors.onSurfaceVariant) @@ -231,7 +233,7 @@ private fun BlacklistedTrackRow( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxMusicSquareRemoveFilled, - contentDescription = "Remove from blacklist", + contentDescription = stringResource(Res.string.blacklist_remove), tint = AppTheme.colors.error) } } @@ -249,7 +251,7 @@ private fun BlacklistedArtistRow( horizontalArrangement = Arrangement.spacedBy(12.dp)) { MillenniumIcon( imageVector = Iconsax.User, - contentDescription = "Artist", + contentDescription = stringResource(Res.string.media_artist), modifier = Modifier.size(20.dp), tint = AppTheme.colors.onSurfaceVariant) @@ -284,7 +286,7 @@ private fun BlacklistedArtistRow( maxLines = 1, overflow = TextOverflow.Ellipsis) Text( - text = "Artist", + text = stringResource(Res.string.media_artist), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) } @@ -294,7 +296,7 @@ private fun BlacklistedArtistRow( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Remove from blacklist", + contentDescription = stringResource(Res.string.blacklist_remove), tint = AppTheme.colors.error) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index 01b115c8..a6b029b7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -56,7 +56,21 @@ import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_connected_to +import spotube.composeapp.generated.resources.devices_connecting +import spotube.composeapp.generated.resources.devices_device_id +import spotube.composeapp.generated.resources.devices_disconnect +import spotube.composeapp.generated.resources.devices_empty +import spotube.composeapp.generated.resources.devices_empty_hint +import spotube.composeapp.generated.resources.devices_refresh +import spotube.composeapp.generated.resources.devices_resolving +import spotube.composeapp.generated.resources.devices_searching +import spotube.composeapp.generated.resources.devices_title +import spotube.composeapp.generated.resources.devices_unknown_device +import spotube.composeapp.generated.resources.jam_error_dismiss @Composable fun DevicesScreen( @@ -86,7 +100,7 @@ fun DevicesScreen( .background(MillenniumTheme.colors.background)) { ApplicationMainBar( backButton = true, - title = { Text("Devices") }, + title = { Text(stringResource(Res.string.devices_title)) }, actions = { if (isDiscovering && connectingToDevice == null) { MillenniumIndeterminateProgress( @@ -96,7 +110,7 @@ fun DevicesScreen( } else { MillenniumIcon( imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Refresh", + contentDescription = stringResource(Res.string.devices_refresh), modifier = Modifier .size(24.dp) .clickable(enabled = connectingToDevice == null) { @@ -111,7 +125,9 @@ fun DevicesScreen( .fillMaxSize() .weight(1f)) { // Error banner - error?.let { errorMessage -> + error?.let { devicesError -> + val errorMessage = devicesError.rawMessage + ?: stringResource(devicesError.message, *devicesError.args.toTypedArray()) Box( modifier = Modifier .fillMaxWidth() @@ -125,7 +141,7 @@ fun DevicesScreen( onClick = { viewModel.clearError() }, modifier = Modifier.padding(top = 8.dp), style = MillenniumButtonStyle.Secondary) { - Text("Dismiss") + Text(stringResource(Res.string.jam_error_dismiss)) } } } @@ -140,14 +156,14 @@ fun DevicesScreen( .padding(16.dp)) { Column { Text( - text = "Connected to ${state.host}:${state.port}", + text = stringResource(Res.string.devices_connected_to, state.host, state.port), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.primary) MillenniumButton( onClick = { viewModel.disconnect() }, modifier = Modifier.padding(top = 8.dp), style = MillenniumButtonStyle.Secondary) { - Text("Disconnect") + Text(stringResource(Res.string.devices_disconnect)) } } } @@ -164,7 +180,7 @@ fun DevicesScreen( MillenniumIndeterminateProgress( modifier = Modifier.size(24.dp)) Text( - text = "Connecting...", + text = stringResource(Res.string.devices_connecting), style = AppTheme.typography.bodyMedium) } } @@ -182,15 +198,15 @@ fun DevicesScreen( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = if (isDiscovering) { - "Searching for devices on the network..." + stringResource(Res.string.devices_searching) } else { - "No devices found" + stringResource(Res.string.devices_empty) }, style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) if (!isDiscovering) { Text( - text = "Make sure the other device has \"Allow remote control\" enabled in settings.", + text = stringResource(Res.string.devices_empty_hint), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp)) @@ -244,7 +260,7 @@ private fun DeviceRow( } Column(modifier = Modifier.weight(1f)) { Text( - text = device.name.ifBlank { "Unknown Device" }, + text = device.name.ifBlank { stringResource(Res.string.devices_unknown_device) }, style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -252,7 +268,7 @@ private fun DeviceRow( text = if (device.host.isNotBlank() && device.port > 0) { "${device.host}:${device.port}" } else { - "Resolving..." + stringResource(Res.string.devices_resolving) }, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, @@ -260,7 +276,7 @@ private fun DeviceRow( overflow = TextOverflow.Ellipsis) if (device.deviceId.isNotBlank()) { Text( - text = "ID: ${device.deviceId.take(8)}...", + text = stringResource(Res.string.devices_device_id, device.deviceId.take(8)), style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f), maxLines = 1, @@ -268,4 +284,4 @@ private fun DeviceRow( } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt index eb2e33b1..8e5089e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -37,6 +37,20 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_error_connect_failed +import spotube.composeapp.generated.resources.devices_error_discovery_failed + +/** + * A user-facing error for the devices screen. [rawMessage] (an untranslated + * cause reported by the transport) takes precedence when present; otherwise the + * composable resolves [message] with [args]. + */ +data class DevicesError( + val message: StringResource, + val args: List = emptyList(), + val rawMessage: String? = null) class DevicesViewModel( private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { @@ -58,8 +72,8 @@ class DevicesViewModel( private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() private var discoveryJob: Job? = null @@ -69,7 +83,7 @@ class DevicesViewModel( remoteControlClient.connectionState.collect { state -> _connectionState.value = state if (state is ConnectionState.Error) { - _error.value = state.message + _error.value = DevicesError(message = state.fallback, rawMessage = state.message) _connectingToDevice.value = null } else if (state is ConnectionState.Disconnected) { _connectingToDevice.value = null @@ -125,7 +139,7 @@ class DevicesViewModel( } } catch (e: Exception) { logger.e(e) { "Discovery failed" } - _error.value = "Discovery failed: ${e.message}" + _error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString())) _isDiscovering.value = false } } @@ -193,7 +207,7 @@ class DevicesViewModel( _connectingToDevice.value = null } catch (e: Exception) { logger.e(e) { "Failed to connect to device" } - _error.value = "Failed to connect: ${e.message}" + _error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString())) _connectingToDevice.value = null } } @@ -208,4 +222,4 @@ class DevicesViewModel( fun clearError() { _error.value = null } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 323cebc4..2aec64e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -42,7 +42,20 @@ import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_destination_action_add_to_queue +import spotube.composeapp.generated.resources.devices_destination_action_play +import spotube.composeapp.generated.resources.devices_destination_action_play_next +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.devices_destination_prompt +import spotube.composeapp.generated.resources.devices_destination_remote_device +import spotube.composeapp.generated.resources.devices_destination_remote_device_host +import spotube.composeapp.generated.resources.devices_destination_remote_subtitle +import spotube.composeapp.generated.resources.devices_destination_this_device +import spotube.composeapp.generated.resources.devices_destination_this_device_subtitle +import spotube.composeapp.generated.resources.devices_destination_title /** * Globally hosted dialog shown when the user tries to play / add to queue / @@ -59,25 +72,25 @@ fun PlayDestinationPickerHost() { val pendingRequest = request ?: return val remoteDeviceName = when (val state = connectionState) { - is ConnectionState.Connected -> "Remote Device (${state.host})" - else -> "Remote Device" + is ConnectionState.Connected -> stringResource(Res.string.devices_destination_remote_device_host, state.host) + else -> stringResource(Res.string.devices_destination_remote_device) } val actionLabel = when (pendingRequest.action) { - PlaybackDestinationAction.Play -> "Play" - PlaybackDestinationAction.AddToQueue -> "Add to queue" - PlaybackDestinationAction.PlayNext -> "Play next" + PlaybackDestinationAction.Play -> stringResource(Res.string.devices_destination_action_play) + PlaybackDestinationAction.AddToQueue -> stringResource(Res.string.devices_destination_action_add_to_queue) + PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next) } MillenniumDialog( visible = true, onDismissRequest = controller::dismissPicker, - title = "Where to $actionLabel?") { + title = stringResource(Res.string.devices_destination_title)) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "$actionLabel \"${pendingRequest.title}\" on:", + text = stringResource(Res.string.devices_destination_prompt, actionLabel, pendingRequest.title), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) @@ -92,12 +105,12 @@ fun PlayDestinationPickerHost() { }, title = { Text( - text = "This Device", + text = stringResource(Res.string.devices_destination_this_device), style = AppTheme.typography.bodyLarge) }, subtitle = { Text( - text = "$actionLabel here", + text = stringResource(Res.string.devices_destination_this_device_subtitle), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) }) @@ -119,7 +132,7 @@ fun PlayDestinationPickerHost() { }, subtitle = { Text( - text = "$actionLabel on the connected device", + text = stringResource(Res.string.devices_destination_remote_subtitle), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) }) @@ -128,8 +141,8 @@ fun PlayDestinationPickerHost() { MillenniumButton( onClick = controller::dismissPicker, style = MillenniumButtonStyle.Quiet) { - Text("Cancel") + Text(stringResource(Res.string.common_cancel)) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index 7ce08e4c..136c3408 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -82,7 +82,29 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_connecting +import spotube.composeapp.generated.resources.devices_disconnect +import spotube.composeapp.generated.resources.devices_remote_album_cover +import spotube.composeapp.generated.resources.devices_remote_connection_error +import spotube.composeapp.generated.resources.devices_remote_disconnected +import spotube.composeapp.generated.resources.devices_remote_loop_mode +import spotube.composeapp.generated.resources.devices_remote_more_options +import spotube.composeapp.generated.resources.devices_remote_next +import spotube.composeapp.generated.resources.devices_remote_previous +import spotube.composeapp.generated.resources.devices_remote_remove_from_queue +import spotube.composeapp.generated.resources.devices_remote_seek +import spotube.composeapp.generated.resources.devices_remote_shuffle +import spotube.composeapp.generated.resources.devices_remote_title +import spotube.composeapp.generated.resources.devices_remote_unknown_artist +import spotube.composeapp.generated.resources.devices_remote_unknown_track +import spotube.composeapp.generated.resources.devices_remote_volume +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.queue_no_queue_entries +import spotube.composeapp.generated.resources.queue_title import kotlin.time.Duration.Companion.milliseconds @Composable @@ -108,7 +130,7 @@ fun RemoteControlScreen( .background(MillenniumTheme.colors.background) ) { ApplicationMainBar( - title = { Text("Remote Control") }, + title = { Text(stringResource(Res.string.devices_remote_title)) }, backButton = true, actions = { MillenniumIconButton( @@ -117,7 +139,7 @@ fun RemoteControlScreen( ) { MillenniumIcon( imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = "Queue", + contentDescription = stringResource(Res.string.queue_title), tint = if (isQueueVisible) { AppTheme.colors.primary } else { @@ -134,7 +156,7 @@ fun RemoteControlScreen( ) { MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Disconnect" + contentDescription = stringResource(Res.string.devices_disconnect) ) } } @@ -162,7 +184,7 @@ fun RemoteControlScreen( .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { - Text("Connecting...") + Text(stringResource(Res.string.devices_connecting)) } } @@ -173,7 +195,7 @@ fun RemoteControlScreen( .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { - Text("Disconnected") + Text(stringResource(Res.string.devices_remote_disconnected)) } } @@ -184,7 +206,11 @@ fun RemoteControlScreen( .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { - Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + val errorState = connectionState as ConnectionState.Error + Text( + stringResource( + Res.string.devices_remote_connection_error, + errorState.message ?: stringResource(errorState.fallback))) } } } @@ -259,7 +285,7 @@ private fun RemoteControlContent( if (playerState.currentTrackCoverUrl != null) { AsyncImage( model = playerState.currentTrackCoverUrl, - contentDescription = "Album cover", + contentDescription = stringResource(Res.string.devices_remote_album_cover), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) @@ -286,7 +312,7 @@ private fun RemoteControlContent( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = playerState.currentTrackTitle ?: "Unknown Track", + text = playerState.currentTrackTitle ?: stringResource(Res.string.devices_remote_unknown_track), style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, @@ -297,7 +323,7 @@ private fun RemoteControlContent( Spacer(modifier = Modifier.height(8.dp)) Text( - text = playerState.currentTrackArtists ?: "Unknown Artist", + text = playerState.currentTrackArtists ?: stringResource(Res.string.devices_remote_unknown_artist), style = AppTheme.typography.bodyLarge, color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, @@ -327,7 +353,7 @@ private fun RemoteControlContent( MillenniumSlider( value = playerState.positionMs.toFloat(), onValueChange = { onSeek(it.toLong()) }, - label = "Seek", + label = stringResource(Res.string.devices_remote_seek), valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), modifier = Modifier.fillMaxWidth() ) @@ -364,7 +390,7 @@ private fun RemoteControlContent( ) { MillenniumIcon( imageVector = Iconsax.IconsaxShuffle, - contentDescription = "Shuffle", + contentDescription = stringResource(Res.string.devices_remote_shuffle), tint = if (playerState.shuffleEnabled) { AppTheme.colors.primary } else { @@ -380,7 +406,7 @@ private fun RemoteControlContent( ) { MillenniumIcon( imageVector = Iconsax.IconsaxPrevious, - contentDescription = "Previous", + contentDescription = stringResource(Res.string.devices_remote_previous), modifier = Modifier.size(32.dp) ) } @@ -396,7 +422,11 @@ private fun RemoteControlContent( } else { Iconsax.IconsaxPlay }, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", + contentDescription = if (playerState.isPlaying) { + stringResource(Res.string.player_pause_content_description) + } else { + stringResource(Res.string.player_play_content_description) + }, modifier = Modifier.size(40.dp), tint = LocalTextStyle.current.color ) @@ -409,7 +439,7 @@ private fun RemoteControlContent( ) { MillenniumIcon( imageVector = Iconsax.IconsaxNext, - contentDescription = "Next", + contentDescription = stringResource(Res.string.devices_remote_next), modifier = Modifier.size(32.dp) ) } @@ -421,7 +451,7 @@ private fun RemoteControlContent( ) { MillenniumIcon( imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = "Loop mode", + contentDescription = stringResource(Res.string.devices_remote_loop_mode), tint = if (playerState.loopMode != "none") { AppTheme.colors.primary } else { @@ -441,7 +471,7 @@ private fun RemoteControlContent( ) { MillenniumIcon( imageVector = Iconsax.IconsaxVolumeHigh, - contentDescription = "Volume", + contentDescription = stringResource(Res.string.devices_remote_volume), tint = AppTheme.colors.onSurfaceVariant, modifier = Modifier.size(24.dp) ) @@ -449,7 +479,7 @@ private fun RemoteControlContent( MillenniumSlider( value = playerState.volume, onValueChange = onSetVolume, - label = "Volume", + label = stringResource(Res.string.devices_remote_volume), valueRange = 0f..1f, modifier = Modifier.weight(1f) ) @@ -473,13 +503,13 @@ private fun RemoteQueueSection( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = "Queue", + text = stringResource(Res.string.queue_title), style = AppTheme.typography.titleLarge ) if (queueState.entries.isEmpty()) { Text( - text = "No queue entries", + text = stringResource(Res.string.queue_no_queue_entries), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant ) @@ -574,7 +604,7 @@ private fun RemoteQueueItemRow( MillenniumDropdownMenu( expanded = showMenu, onExpandedChange = { showMenu = it }, - items = listOf("Remove from queue"), + items = listOf(stringResource(Res.string.devices_remote_remove_from_queue)), onItemClick = { onRemoveClick() showMenu = false @@ -586,7 +616,7 @@ private fun RemoteQueueItemRow( ) { MillenniumIcon( Iconsax.Iconsax3DotsMore, - contentDescription = "More options", + contentDescription = stringResource(Res.string.devices_remote_more_options), modifier = Modifier.size(18.dp) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt index 8330c0e8..4c7a49e0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt @@ -57,6 +57,8 @@ import okio.SYSTEM import okio.buffer import okio.use import kotlin.time.Clock +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* data class DownloadItem( val id: String, @@ -196,11 +198,12 @@ class DownloadManager( private suspend fun executeDownload(item: DownloadItem) { val track = item.track ?: run { logger.e { "Download item has no track metadata" } + val noMetadataMessage = getString(Res.string.downloads_error_no_metadata) downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy( - status = DownloadStatus.Failed("No track metadata"), - errorMessage = "No track metadata")) + status = DownloadStatus.Failed(noMetadataMessage), + errorMessage = noMetadataMessage)) } ?: map } return @@ -210,11 +213,13 @@ class DownloadManager( val streamInfo = streamingUrlRepository.resolveStreamInfo(track) if (streamInfo == null) { logger.w { "Could not resolve stream URL for ${track.title}" } + val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short) + val unresolvedMessage = getString(Res.string.downloads_error_stream_unresolved) downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy( - status = DownloadStatus.Failed("Could not resolve stream URL"), - errorMessage = "Could not resolve stream URL. Try playing the track first.")) + status = DownloadStatus.Failed(unresolvedStatus), + errorMessage = unresolvedMessage)) } ?: map } return @@ -254,10 +259,11 @@ class DownloadManager( throw e } catch (e: Exception) { logger.e(e) { "Download failed: ${item.title}" } + val failureMessage = e.message ?: getString(Res.string.common_unknown_error) downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy( - status = DownloadStatus.Failed(e.message ?: "Unknown error"), + status = DownloadStatus.Failed(failureMessage), errorMessage = e.message)) } ?: map } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index 4cd088ed..15506867 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -86,6 +86,9 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.support_button_label +import spotube.composeapp.generated.resources.devices_title +import spotube.composeapp.generated.resources.home_title_browse +import spotube.composeapp.generated.resources.jam_screen_title import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.jetbrains.compose.resources.Font @@ -147,7 +150,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { color = AppTheme.colors.onSurface ) } else { - Text("Browse") + Text(stringResource(Res.string.home_title_browse)) } }, @@ -159,7 +162,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { ) { MillenniumIcon( imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = "Devices" + contentDescription = stringResource(Res.string.devices_title) ) } MillenniumIconButton( @@ -168,7 +171,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { ) { MillenniumIcon( imageVector = Iconsax.User, - contentDescription = "Group Jam" + contentDescription = stringResource(Res.string.jam_screen_title) ) } val supportLabel = stringResource(Res.string.support_button_label) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt index 667fe23f..297e64e2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt @@ -35,6 +35,9 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface HomeScreenState { data object Loading : HomeScreenState @@ -111,7 +114,7 @@ class HomeScreenViewModel( paginationStrategies = paginationStrategies) }.onFailure { e -> logger.e(e) { "Failed to load home screen data" } - state.value = HomeScreenState.Error(e.message ?: "Unknown error") + state.value = HomeScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun selectGenre(genreId: String) { @@ -155,7 +158,7 @@ class HomeScreenViewModel( } }.onFailure { e -> logger.e(e) { "Failed to load more home screen data" } - state.value = HomeScreenState.Error(e.message ?: "Unknown error") + state.value = HomeScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun refresh() { @@ -164,4 +167,4 @@ class HomeScreenViewModel( loadInitialData() } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt index c9dc4bca..adff1af3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt @@ -27,6 +27,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_destination_message +import spotube.composeapp.generated.resources.jam_destination_play_here +import spotube.composeapp.generated.resources.jam_destination_suggest +import spotube.composeapp.generated.resources.jam_destination_title +import spotube.composeapp.generated.resources.common_cancel /** * Dialog shown when the user tries to play a collection while a jam session is active. @@ -43,24 +50,24 @@ fun PlayDestinationPicker( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = "Play Where?") { + title = stringResource(Res.string.jam_destination_title)) { Text( - text = "You have an active jam session. Choose where to play this collection.", + text = stringResource(Res.string.jam_destination_message), style = AppTheme.typography.bodyMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { MillenniumButton( onClick = onDismiss, style = MillenniumButtonStyle.Quiet) { - Text("Cancel") + Text(stringResource(Res.string.common_cancel)) } MillenniumButton( onClick = onPlayLocally, style = MillenniumButtonStyle.Quiet) { - Text("Play here") + Text(stringResource(Res.string.jam_destination_play_here)) } MillenniumButton(onClick = onSuggestToJam) { - Text("Suggest to Jam") + Text(stringResource(Res.string.jam_destination_suggest)) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt index 3c9abd4d..6027cc2c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt @@ -27,12 +27,12 @@ import dev.krtirtho.spotube.resources.iconsax.User import org.jetbrains.compose.resources.StringResource import spotube.composeapp.generated.resources.* -enum class LibraryTab(val title: String, val icon: ImageVector) { - Playlists("Playlists", Iconsax.IconsaxMusicDashboard), - Albums("Albums", Iconsax.IconsaxCd), - Artists("Artists", Iconsax.User), - LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary), - Downloads("Downloads", Iconsax.IconsaxDirectboxReceive); +enum class LibraryTab(val icon: ImageVector) { + Playlists(Iconsax.IconsaxMusicDashboard), + Albums(Iconsax.IconsaxCd), + Artists(Iconsax.User), + LocalTracks(Iconsax.IconsaxMusicLibrary), + Downloads(Iconsax.IconsaxDirectboxReceive); val titleResource: StringResource get() = when (this) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 2f9de499..e2f9d7ee 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -36,6 +36,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface LibraryAlbumsState { data object Loading : LibraryAlbumsState @@ -131,7 +134,7 @@ class LibraryAlbumsViewModel( ) }.onFailure { e -> logger.e(e) { "Failed to load albums" } - _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") + _state.value = LibraryAlbumsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreData() = runCatching { @@ -147,7 +150,7 @@ class LibraryAlbumsViewModel( } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } - _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") + _state.value = LibraryAlbumsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun onQueryChange(query: String) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt index 7e8cb1df..694d3595 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -36,6 +36,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface LibraryArtistsState { data object Loading : LibraryArtistsState @@ -131,7 +134,7 @@ class LibraryArtistsViewModel( nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load artists" } - _state.value = LibraryArtistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryArtistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreData() = runCatching { @@ -155,7 +158,7 @@ class LibraryArtistsViewModel( } }.onFailure { e -> logger.e(e) { "Failed to load more artists" } - _state.value = LibraryArtistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryArtistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun onQueryChange(query: String) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt index d5908f90..6c84fd81 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -67,6 +67,16 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_add_to_playlist_empty +import spotube.composeapp.generated.resources.library_add_to_playlist_loading +import spotube.composeapp.generated.resources.library_add_to_playlist_search_content_description +import spotube.composeapp.generated.resources.library_add_to_playlist_search_placeholder +import spotube.composeapp.generated.resources.library_add_to_playlist_title +import spotube.composeapp.generated.resources.library_local_tracks_count +import spotube.composeapp.generated.resources.common_cancel @Composable fun AddToPlaylistPicker( @@ -126,12 +136,12 @@ fun AddToPlaylistPicker( horizontalArrangement = Arrangement.spacedBy(8.dp)) { MillenniumIcon( imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search") + contentDescription = stringResource(Res.string.library_add_to_playlist_search_content_description)) MillenniumTextField( state = searchState, modifier = Modifier.weight(1f), - placeholder = "Search your playlists...") + placeholder = stringResource(Res.string.library_add_to_playlist_search_placeholder)) } PlaylistPickerContent( @@ -148,7 +158,7 @@ fun AddToPlaylistPicker( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = "Add to Your Playlist", + title = stringResource(Res.string.library_add_to_playlist_title), modifier = modifier) { Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { content() @@ -156,7 +166,7 @@ fun AddToPlaylistPicker( MillenniumButton( onClick = onDismiss, style = MillenniumButtonStyle.Secondary) { - Text("Cancel") + Text(stringResource(Res.string.common_cancel)) } } } else { @@ -167,7 +177,7 @@ fun AddToPlaylistPicker( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp)) { Text( - text = "Add to Your Playlist", + text = stringResource(Res.string.library_add_to_playlist_title), style = AppTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp)) content() @@ -183,11 +193,11 @@ private fun PlaylistPickerContent( onPlaylistSelected: (String) -> Unit) { if (isLoading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Loading your playlists...") + Text(stringResource(Res.string.library_add_to_playlist_loading)) } } else if (playlists.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("You don't own any playlists yet.\nCreate one to add tracks.") + Text(stringResource(Res.string.library_add_to_playlist_empty)) } } else { LazyColumn( @@ -242,7 +252,7 @@ private fun PlaylistPickerRow( maxLines = 1, overflow = TextOverflow.Ellipsis) Text( - text = "${playlist.trackCount} tracks", + text = pluralStringResource(Res.plurals.library_local_tracks_count, playlist.trackCount, playlist.trackCount), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 4c6662aa..882b9d2b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -36,6 +36,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface LibraryPlaylistsState { data object Loading : LibraryPlaylistsState @@ -127,7 +130,7 @@ class LibraryPlaylistsViewModel( ) }.onFailure { e -> logger.e(e) { "Failed to load playlists" } - _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryPlaylistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreData() = runCatching { @@ -143,7 +146,7 @@ class LibraryPlaylistsViewModel( } }.onFailure { e -> logger.e(e) { "Failed to load more playlists" } - _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryPlaylistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun onQueryChange(query: String) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index ffcf1dfb..7551a157 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -67,6 +67,26 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_playlist_form_change_image +import spotube.composeapp.generated.resources.library_playlist_form_collaborative +import spotube.composeapp.generated.resources.library_playlist_form_collaborative_description +import spotube.composeapp.generated.resources.library_playlist_form_cover_content_description +import spotube.composeapp.generated.resources.library_playlist_form_cover_image +import spotube.composeapp.generated.resources.library_playlist_form_create +import spotube.composeapp.generated.resources.library_playlist_form_create_title +import spotube.composeapp.generated.resources.library_playlist_form_description_label +import spotube.composeapp.generated.resources.library_playlist_form_description_placeholder +import spotube.composeapp.generated.resources.library_playlist_form_edit_title +import spotube.composeapp.generated.resources.library_playlist_form_name_label +import spotube.composeapp.generated.resources.library_playlist_form_name_placeholder +import spotube.composeapp.generated.resources.library_playlist_form_no_image +import spotube.composeapp.generated.resources.library_playlist_form_pick_image +import spotube.composeapp.generated.resources.library_playlist_form_public +import spotube.composeapp.generated.resources.library_playlist_form_public_description +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.common_save data class PlaylistFormData( val name: String = "", @@ -146,7 +166,7 @@ fun PlaylistFormSheet( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = if (isEditing) "Edit Playlist" else "Create Playlist", + title = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title), modifier = modifier ) { PlaylistFormFields( @@ -168,13 +188,13 @@ fun PlaylistFormSheet( }, enabled = name.isNotBlank() ) { - Text(if (isEditing) "Save" else "Create") + Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) } MillenniumButton( onClick = onDismiss, style = MillenniumButtonStyle.Secondary ) { - Text("Cancel") + Text(stringResource(Res.string.common_cancel)) } } } @@ -187,7 +207,7 @@ fun PlaylistFormSheet( .padding(horizontal = 16.dp, vertical = 8.dp) ) { Text( - text = if (isEditing) "Edit Playlist" else "Create Playlist", + text = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title), style = AppTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp) ) @@ -211,7 +231,7 @@ fun PlaylistFormSheet( onClick = onDismiss, style = MillenniumButtonStyle.Secondary ) { - Text("Cancel") + Text(stringResource(Res.string.common_cancel)) } MillenniumButton( onClick = { @@ -220,7 +240,7 @@ fun PlaylistFormSheet( }, enabled = name.isNotBlank() ) { - Text(if (isEditing) "Save" else "Create") + Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) } } Spacer(modifier = Modifier.height(8.dp)) @@ -249,16 +269,16 @@ private fun PlaylistFormFields( ) { MillenniumTextField( state = nameState, - label = "Name", + label = stringResource(Res.string.library_playlist_form_name_label), modifier = Modifier.fillMaxWidth(), - placeholder = "Playlist name" + placeholder = stringResource(Res.string.library_playlist_form_name_placeholder) ) MillenniumTextField( state = descriptionState, - label = "Description", + label = stringResource(Res.string.library_playlist_form_description_label), modifier = Modifier.fillMaxWidth(), - placeholder = "Description (optional)" + placeholder = stringResource(Res.string.library_playlist_form_description_placeholder) ) Row( @@ -270,9 +290,9 @@ private fun PlaylistFormFields( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Public", style = AppTheme.typography.bodyLarge) + Text(stringResource(Res.string.library_playlist_form_public), style = AppTheme.typography.bodyLarge) Text( - "Anyone can see this playlist", + stringResource(Res.string.library_playlist_form_public_description), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant ) @@ -289,9 +309,9 @@ private fun PlaylistFormFields( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Collaborative", style = AppTheme.typography.bodyLarge) + Text(stringResource(Res.string.library_playlist_form_collaborative), style = AppTheme.typography.bodyLarge) Text( - "Others can add tracks to this playlist", + stringResource(Res.string.library_playlist_form_collaborative_description), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant ) @@ -303,7 +323,7 @@ private fun PlaylistFormFields( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Cover Image", style = AppTheme.typography.bodyLarge) + Text(stringResource(Res.string.library_playlist_form_cover_image), style = AppTheme.typography.bodyLarge) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically @@ -317,7 +337,7 @@ private fun PlaylistFormFields( when { imagePreviewUrl != null -> AsyncImage( model = imagePreviewUrl, - contentDescription = "Playlist cover", + contentDescription = stringResource(Res.string.library_playlist_form_cover_content_description), modifier = Modifier.size(64.dp), contentScale = ContentScale.Crop ) @@ -332,7 +352,7 @@ private fun PlaylistFormFields( else -> { Text( - "No image", + stringResource(Res.string.library_playlist_form_no_image), style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant ) @@ -340,7 +360,7 @@ private fun PlaylistFormFields( } } MillenniumButton(onClick = onPickImage) { - Text(if (imageBase64.isNotBlank() || imagePreviewUrl != null) "Change Image" else "Pick Image") + Text(stringResource(if (imageBase64.isNotBlank() || imagePreviewUrl != null) Res.string.library_playlist_form_change_image else Res.string.library_playlist_form_pick_image)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index 5d98d25b..5bc23b20 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -64,7 +64,15 @@ import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error +import spotube.composeapp.generated.resources.lyrics_mode_plain +import spotube.composeapp.generated.resources.lyrics_mode_synced +import spotube.composeapp.generated.resources.lyrics_no_plain +import spotube.composeapp.generated.resources.lyrics_no_synced +import spotube.composeapp.generated.resources.common_close @Composable fun LyricsScreen( @@ -88,7 +96,7 @@ fun LyricsScreen( ) { MillenniumIcon( imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = "Close" + contentDescription = stringResource(Res.string.common_close) ) } } else { @@ -103,12 +111,12 @@ fun LyricsScreen( horizontalArrangement = Arrangement.spacedBy(3.dp) ) { LyricsModeTab( - label = "Synced", + label = stringResource(Res.string.lyrics_mode_synced), isSelected = uiState.mode == LyricType.SYNCED, onClick = { viewModel.setMode(LyricType.SYNCED) } ) LyricsModeTab( - label = "Plain", + label = stringResource(Res.string.lyrics_mode_plain), isSelected = uiState.mode == LyricType.STATIC, onClick = { viewModel.setMode(LyricType.STATIC) } ) @@ -140,7 +148,7 @@ fun LyricsScreen( contentAlignment = Alignment.Center ) { Text( - text = uiState.error ?: "Unknown error", + text = uiState.error ?: stringResource(Res.string.common_unknown_error), style = AppTheme.typography.bodyLarge, color = AppTheme.colors.error, textAlign = TextAlign.Center @@ -210,7 +218,7 @@ private fun SyncedLyricsContent( contentAlignment = Alignment.Center ) { Text( - text = "No synced lyrics available", + text = stringResource(Res.string.lyrics_no_synced), style = AppTheme.typography.bodyLarge, color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center @@ -296,7 +304,7 @@ private fun PlainLyricsContent( contentAlignment = Alignment.Center ) { Text( - text = "No plain lyrics available", + text = stringResource(Res.string.lyrics_no_plain), style = AppTheme.typography.bodyLarge, color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center @@ -318,4 +326,4 @@ private fun PlainLyricsContent( ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index ef9d8a74..dfe87d1d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -72,6 +72,16 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.playlist_add_tracks_add +import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description +import spotube.composeapp.generated.resources.playlist_add_tracks_done +import spotube.composeapp.generated.resources.playlist_add_tracks_hint +import spotube.composeapp.generated.resources.playlist_add_tracks_no_results +import spotube.composeapp.generated.resources.playlist_add_tracks_search_content_description +import spotube.composeapp.generated.resources.playlist_add_tracks_search_placeholder +import spotube.composeapp.generated.resources.playlist_add_tracks_title @Composable fun AddTracksToPlaylistDialog( @@ -144,10 +154,10 @@ fun AddTracksToPlaylistDialog( MillenniumTextField( state = queryState, modifier = Modifier.weight(1f), - placeholder = "Search tracks...") + placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder)) MillenniumIconButton( icon = Iconsax.IconsaxSearch, - contentDescription = "Search", + contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description), onClick = handleSearch, style = MillenniumIconButtonStyle.Secondary) } @@ -168,7 +178,7 @@ fun AddTracksToPlaylistDialog( .fillMaxWidth() .weight(1f), contentAlignment = Alignment.Center) { - Text("No tracks found") + Text(stringResource(Res.string.playlist_add_tracks_no_results)) } } searchResults.isNotEmpty() -> { @@ -192,7 +202,7 @@ fun AddTracksToPlaylistDialog( .fillMaxWidth() .weight(1f), contentAlignment = Alignment.Center) { - Text("Search for tracks to add") + Text(stringResource(Res.string.playlist_add_tracks_hint)) } } } @@ -203,13 +213,13 @@ fun AddTracksToPlaylistDialog( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = "Add Tracks to Playlist", + title = stringResource(Res.string.playlist_add_tracks_title), modifier = modifier) { Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { content() } MillenniumButton(onClick = onDismiss) { - Text("Done") + Text(stringResource(Res.string.playlist_add_tracks_done)) } } } else { @@ -221,7 +231,7 @@ fun AddTracksToPlaylistDialog( .padding(horizontal = 16.dp, vertical = 8.dp) .height(600.dp)) { Text( - text = "Add Tracks to Playlist", + text = stringResource(Res.string.playlist_add_tracks_title), style = AppTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp)) content() @@ -282,7 +292,7 @@ private fun TrackSearchResultRow( isAdded -> { MillenniumIcon( imageVector = Iconsax.IconsaxCheckCircle, - contentDescription = "Added", + contentDescription = stringResource(Res.string.playlist_add_tracks_added_content_description), tint = AppTheme.colors.primary, modifier = Modifier.size(24.dp)) } @@ -294,7 +304,7 @@ private fun TrackSearchResultRow( MillenniumButton( onClick = onAdd, style = MillenniumButtonStyle.Secondary) { - Text("Add") + Text(stringResource(Res.string.playlist_add_tracks_add)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 09ae39bb..a80c867c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -51,6 +51,11 @@ import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.playlist_add_tracks_button +import spotube.composeapp.generated.resources.playlist_loading_title +import spotube.composeapp.generated.resources.playlist_owner_unknown @Composable fun PlaylistScreen( @@ -78,7 +83,7 @@ fun PlaylistScreen( val dataState = state as? PlaylistScreenState.Data val playlist = dataState?.playlist val owner = playlist?.owner - val ownerName = owner?.displayName ?: owner?.username ?: "Unknown" + val ownerName = owner?.displayName ?: owner?.username ?: stringResource(Res.string.playlist_owner_unknown) val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty() @@ -104,7 +109,7 @@ fun PlaylistScreen( MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, contentDescription = null) - Text("Add Tracks") + Text(stringResource(Res.string.playlist_add_tracks_button)) } } } @@ -112,7 +117,7 @@ fun PlaylistScreen( } else null CollectionView( - title = playlist?.title ?: "Loading playlist...", + title = playlist?.title ?: stringResource(Res.string.playlist_loading_title), description = playlist?.description.orEmpty(), imageURL = artworkUrl, ownerName = ownerName, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index ec441718..589e13cf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -50,6 +50,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface PlaylistScreenState { data object Loading : PlaylistScreenState @@ -174,7 +177,7 @@ class PlaylistViewModel( nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load playlist" } - _state.value = PlaylistScreenState.Error(e.message ?: "Unknown error") + _state.value = PlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreTracks() = runCatching { @@ -193,7 +196,7 @@ class PlaylistViewModel( } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } - _state.value = PlaylistScreenState.Error(e.message ?: "Unknown error") + _state.value = PlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun loadNextTracksPage() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 5d13734d..557c0710 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -61,7 +61,35 @@ import okio.Path.Companion.toPath import okio.SYSTEM import okio.buffer import okio.use +import org.jetbrains.compose.resources.getString import org.koin.core.component.KoinComponent +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_action_install +import spotube.composeapp.generated.resources.plugin_action_replace +import spotube.composeapp.generated.resources.plugin_error_api_incompatible +import spotube.composeapp.generated.resources.plugin_error_api_version_invalid +import spotube.composeapp.generated.resources.plugin_error_app_api_version_invalid +import spotube.composeapp.generated.resources.plugin_error_builtin_add +import spotube.composeapp.generated.resources.plugin_error_builtin_remove +import spotube.composeapp.generated.resources.plugin_error_install_failed +import spotube.composeapp.generated.resources.plugin_error_invalid_plugin_json +import spotube.composeapp.generated.resources.plugin_error_plugin_json_missing +import spotube.composeapp.generated.resources.plugin_error_read_failed +import spotube.composeapp.generated.resources.plugin_error_replace_needs_confirmation +import spotube.composeapp.generated.resources.plugin_error_update_needs_confirmation +import spotube.composeapp.generated.resources.plugin_error_zipline_manifest_missing +import spotube.composeapp.generated.resources.plugin_prompt_incompatible_title +import spotube.composeapp.generated.resources.plugin_prompt_install_message +import spotube.composeapp.generated.resources.plugin_prompt_install_title +import spotube.composeapp.generated.resources.plugin_prompt_replace_downgrade +import spotube.composeapp.generated.resources.plugin_prompt_replace_generic +import spotube.composeapp.generated.resources.plugin_prompt_replace_invalid_incoming +import spotube.composeapp.generated.resources.plugin_prompt_replace_invalid_installed +import spotube.composeapp.generated.resources.plugin_prompt_replace_same_version +import spotube.composeapp.generated.resources.plugin_prompt_replace_title +import spotube.composeapp.generated.resources.plugin_prompt_update_message +import spotube.composeapp.generated.resources.plugin_prompt_update_title +import spotube.composeapp.generated.resources.update_action_update const val PLUGIN_API_VERSION = "0.0.1" @@ -328,7 +356,7 @@ class PluginManager( val pluginJsonPath = tempDir / "plugin.json".toPath() if (!FileSystem.SYSTEM.exists(pluginJsonPath)) { - throw IllegalArgumentException("plugin.json not found in the zip file") + throw IllegalArgumentException(getString(Res.string.plugin_error_plugin_json_missing)) } val pluginJson = @@ -336,7 +364,7 @@ class PluginManager( val pluginEntry = try { Json.decodeFromString(pluginJson) } catch (e: Exception) { - throw IllegalArgumentException("Invalid plugin.json format: ${e.message}") + throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } // Preserve logo.png before deleting temp dir so the permission dialog can show it @@ -350,7 +378,7 @@ class PluginManager( pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes) } catch (e: Exception) { - throw Exception("Failed to read plugin: ${e.message}", e) + throw Exception(getString(Res.string.plugin_error_read_failed, e.message.orEmpty()), e) } finally { if (FileSystem.SYSTEM.exists(tempDir)) { try { @@ -436,11 +464,11 @@ class PluginManager( val pluginJsonPath = tempDir / "plugin.json".toPath() if (!FileSystem.SYSTEM.exists(pluginJsonPath)) { - throw IllegalArgumentException("plugin.json not found in the zip file") + throw IllegalArgumentException(getString(Res.string.plugin_error_plugin_json_missing)) } val pluginManifestPath = tempDir / "manifest.zipline.json".toPath() if (!FileSystem.SYSTEM.exists(pluginManifestPath)) { - throw IllegalArgumentException("manifest.zipline.json not found in the zip file") + throw IllegalArgumentException(getString(Res.string.plugin_error_zipline_manifest_missing)) } val pluginJson = FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { source -> @@ -450,7 +478,7 @@ class PluginManager( val pluginEntry = try { Json.decodeFromString(pluginJson) } catch (e: Exception) { - throw IllegalArgumentException("Invalid plugin.json format: ${e.message}") + throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } ensurePluginApiCompatible(pluginEntry) @@ -459,8 +487,8 @@ class PluginManager( if (installedPlugin != null && !allowReplacingInstalled) { throw IllegalArgumentException( when (compareVersions(installedPlugin, pluginEntry)) { - VersionRelation.Update -> "A newer version of ${pluginEntry.name} is available. Confirm the update before installing." - VersionRelation.Replace -> "${pluginEntry.name} is already installed. Confirm replacing it before installing." + VersionRelation.Update -> getString(Res.string.plugin_error_update_needs_confirmation, pluginEntry.name) + VersionRelation.Replace -> getString(Res.string.plugin_error_replace_needs_confirmation, pluginEntry.name) } ) } @@ -490,7 +518,7 @@ class PluginManager( addPlugin(pluginEntry) } catch (e: Exception) { - throw Exception("Failed to install plugin: ${e.message}", e) + throw Exception(getString(Res.string.plugin_error_install_failed, e.message.orEmpty()), e) } finally { if (FileSystem.SYSTEM.exists(tempDir)) { try { @@ -521,7 +549,7 @@ class PluginManager( private suspend fun addPlugin(plugin: PluginEntry) { if (plugin in BUILT_IN_PLUGINS) { - throw IllegalArgumentException("Built-in plugins are already included and can't be added again.") + throw IllegalArgumentException(getString(Res.string.plugin_error_builtin_add)) } val currentState = state.value @@ -543,7 +571,7 @@ class PluginManager( suspend fun removePlugin(plugin: PluginEntry) { if (plugin in BUILT_IN_PLUGINS) { - throw IllegalArgumentException("Built-in plugins can't be removed.") + throw IllegalArgumentException(getString(Res.string.plugin_error_builtin_remove)) } val currentState = state.value @@ -589,46 +617,49 @@ class PluginManager( Replace, } - private fun buildPendingPlugin(pluginEntry: PluginEntry, bytes: ByteArray): PendingPlugin { + private suspend fun buildPendingPlugin(pluginEntry: PluginEntry, bytes: ByteArray): PendingPlugin { val apiError = getPluginApiCompatibilityError(pluginEntry) if (apiError != null) { return PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.INFO, - title = "Plugin API not compatible", + title = getString(Res.string.plugin_prompt_incompatible_title), message = apiError) } val installedPlugin = findInstalledPlugin(pluginEntry.id) ?: return PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.INSTALL, - title = "Install plugin?", - message = "${pluginEntry.name} will be added to your installed plugins.", + title = getString(Res.string.plugin_prompt_install_title), + message = getString(Res.string.plugin_prompt_install_message, pluginEntry.name), bytes = bytes, - confirmLabel = "Install") + confirmLabel = getString(Res.string.plugin_action_install)) return when (compareVersions(installedPlugin, pluginEntry)) { VersionRelation.Update -> PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.UPDATE, - title = "Update plugin?", - message = "An older version is installed (${installedPlugin.version}). Update to ${pluginEntry.version}?", + title = getString(Res.string.plugin_prompt_update_title), + message = getString( + Res.string.plugin_prompt_update_message, + installedPlugin.version, + pluginEntry.version), bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Update") + confirmLabel = getString(Res.string.update_action_update)) VersionRelation.Replace -> PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.REPLACE, - title = "Replace installed plugin?", + title = getString(Res.string.plugin_prompt_replace_title), message = buildReplaceMessage(installedPlugin, pluginEntry), bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Replace") + confirmLabel = getString(Res.string.plugin_action_replace)) } } - private fun buildReplaceMessage( + private suspend fun buildReplaceMessage( installedPlugin: PluginEntry, incomingPlugin: PluginEntry ): String { @@ -636,11 +667,22 @@ class PluginManager( val installedVersion = parseSemVerOrNull(installedPlugin.version) return when { - incomingVersion == null -> "${incomingPlugin.name} uses an invalid semantic version (${incomingPlugin.version}) and can't be compared as an update. Replace the installed plugin anyway?" - installedVersion == null -> "The installed version (${installedPlugin.version}) can't be compared using semantic versioning. Replace it with ${incomingPlugin.version}?" - incomingVersion == installedVersion -> "Version ${incomingPlugin.version} is already installed. Replace the existing plugin with the supplied copy?" - incomingVersion < installedVersion -> "Installed version ${installedPlugin.version} is newer than the supplied version ${incomingPlugin.version}. Replace it anyway?" - else -> "Replace the installed plugin with the supplied copy?" + incomingVersion == null -> getString( + Res.string.plugin_prompt_replace_invalid_incoming, + incomingPlugin.name, + incomingPlugin.version) + installedVersion == null -> getString( + Res.string.plugin_prompt_replace_invalid_installed, + installedPlugin.version, + incomingPlugin.version) + incomingVersion == installedVersion -> getString( + Res.string.plugin_prompt_replace_same_version, + incomingPlugin.version) + incomingVersion < installedVersion -> getString( + Res.string.plugin_prompt_replace_downgrade, + installedPlugin.version, + incomingPlugin.version) + else -> getString(Res.string.plugin_prompt_replace_generic) } } @@ -671,17 +713,21 @@ class PluginManager( } } - private fun ensurePluginApiCompatible(pluginEntry: PluginEntry) { + private suspend fun ensurePluginApiCompatible(pluginEntry: PluginEntry) { getPluginApiCompatibilityError(pluginEntry)?.let { error -> throw IllegalArgumentException(error) } } - private fun getPluginApiCompatibilityError(pluginEntry: PluginEntry): String? { + private suspend fun getPluginApiCompatibilityError(pluginEntry: PluginEntry): String? { val pluginApiVersion = parseSemVerOrNull(pluginEntry.apiVersion) - ?: return "${pluginEntry.name} declares an invalid apiVersion (${pluginEntry.apiVersion}). Expected semantic versioning compatible with $PLUGIN_API_VERSION." + ?: return getString( + Res.string.plugin_error_api_version_invalid, + pluginEntry.name, + pluginEntry.apiVersion, + PLUGIN_API_VERSION) val appApiVersion = parseSemVerOrNull(PLUGIN_API_VERSION) - ?: return "Spotube plugin API version $PLUGIN_API_VERSION is invalid." + ?: return getString(Res.string.plugin_error_app_api_version_invalid, PLUGIN_API_VERSION) val compatible = if (pluginApiVersion.major == 0 || appApiVersion.major == 0) { pluginApiVersion.major == appApiVersion.major && pluginApiVersion.minor == appApiVersion.minor @@ -692,7 +738,11 @@ class PluginManager( return if (compatible) { null } else { - "${pluginEntry.name} targets plugin API ${pluginEntry.apiVersion}, but Spotube supports breaking plugin API $PLUGIN_API_VERSION. Install a plugin built for the same breaking API version." + getString( + Res.string.plugin_error_api_incompatible, + pluginEntry.name, + pluginEntry.apiVersion, + PLUGIN_API_VERSION) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index 2942c39a..5ddffeb0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -109,9 +109,26 @@ import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import okio.Path import kotlinx.coroutines.flow.collect +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_discover_badge_official +import spotube.composeapp.generated.resources.plugin_discover_badge_verified +import spotube.composeapp.generated.resources.plugin_discover_repository_content_description +import spotube.composeapp.generated.resources.plugin_discover_stars_content_description +import spotube.composeapp.generated.resources.plugin_info_abilities +import spotube.composeapp.generated.resources.plugin_info_api_version +import spotube.composeapp.generated.resources.plugin_info_author +import spotube.composeapp.generated.resources.plugin_info_capabilities +import spotube.composeapp.generated.resources.plugin_info_contact +import spotube.composeapp.generated.resources.plugin_info_label_format +import spotube.composeapp.generated.resources.plugin_info_license +import spotube.composeapp.generated.resources.plugin_info_report_bugs +import spotube.composeapp.generated.resources.plugin_info_repository +import spotube.composeapp.generated.resources.plugin_info_version +import spotube.composeapp.generated.resources.plugin_support_dialog_title +import spotube.composeapp.generated.resources.common_close import spotube.composeapp.generated.resources.welcome_plugins_title import spotube.composeapp.generated.resources.welcome_plugins_setup_guide import spotube.composeapp.generated.resources.welcome_plugins_discover @@ -129,9 +146,7 @@ import spotube.composeapp.generated.resources.plugin_error_download_failed import spotube.composeapp.generated.resources.plugin_error_enter_url import spotube.composeapp.generated.resources.plugin_error_url_scheme import spotube.composeapp.generated.resources.plugin_install_section_title -import spotube.composeapp.generated.resources.plugin_installed_count -import spotube.composeapp.generated.resources.plugin_installed_plural -import spotube.composeapp.generated.resources.plugin_installed_singular +import spotube.composeapp.generated.resources.plugin_installed_plugins_count import spotube.composeapp.generated.resources.plugin_screen_title import spotube.composeapp.generated.resources.plugin_section_file_title import spotube.composeapp.generated.resources.plugin_section_install @@ -385,16 +400,11 @@ internal fun PluginScreenContent( } } else { item { - val noun = if (data.plugins.size == 1) { - stringResource(Res.string.plugin_installed_singular) - } else { - stringResource(Res.string.plugin_installed_plural) - } Text( - stringResource( - Res.string.plugin_installed_count, + pluralStringResource( + Res.plurals.plugin_installed_plugins_count, data.plugins.size, - noun + data.plugins.size ), style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant, @@ -720,47 +730,44 @@ private fun PluginInfoDialog( ) } - DetailRow("Version", plugin.version) - DetailRow("API Version", plugin.apiVersion) - DetailRow("Author", plugin.author) + DetailRow(stringResource(Res.string.plugin_info_version), plugin.version) + DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion) + DetailRow(stringResource(Res.string.plugin_info_author), plugin.author) if (plugin.license.isNotBlank()) { - DetailRow("License", plugin.license) + DetailRow(stringResource(Res.string.plugin_info_license), plugin.license) } if (plugin.capabilities.isNotEmpty()) { - DetailChipsRow("Capabilities", plugin.capabilities.map { it.name }) + DetailChipsRow( + stringResource(Res.string.plugin_info_capabilities), + plugin.capabilities.map { it.name }) } if (plugin.abilities.isNotEmpty()) { - DetailChipsRow("Abilities", plugin.abilities.map { ability -> - when (ability) { - PluginAbility.METADATA -> "Metadata" - PluginAbility.AUDIO -> "Audio" - PluginAbility.LYRICS -> "Lyrics" - PluginAbility.SCROBBLE -> "Scrobble" - } - }) + DetailChipsRow( + stringResource(Res.string.plugin_info_abilities), + plugin.abilities.map { ability -> ability.displayLabel() }) } if (plugin.repository.isNotBlank()) { - ClickableDetailRow(label = "Repository", value = plugin.repository) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) { openUrlInBrowser(plugin.repository) } } if (plugin.contact.isNotBlank()) { - DetailRow("Contact", plugin.contact) + DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact) } if (plugin.bugs.isNotBlank()) { - ClickableDetailRow(label = "Report Bugs", value = plugin.bugs) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) { openUrlInBrowser(plugin.bugs) } } } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumButton(onClick = onDismiss) { - Text("Close") + Text(stringResource(Res.string.common_close)) } } } @@ -775,7 +782,7 @@ private fun SupportDialog( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = "Support ${plugin.name}") { + title = stringResource(Res.string.plugin_support_dialog_title, plugin.name)) { if (isLoading) { Box( modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), @@ -792,7 +799,7 @@ private fun SupportDialog( } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumButton(onClick = onDismiss) { - Text("Close") + Text(stringResource(Res.string.common_close)) } } } @@ -861,7 +868,7 @@ private fun DiscoverRepoCard( .background(AppTheme.colors.primary.copy(alpha = 0.15f)) ) { Text( - "Official", + stringResource(Res.string.plugin_discover_badge_official), style = AppTheme.typography.labelSmall, color = AppTheme.colors.primary, modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) @@ -885,7 +892,7 @@ private fun DiscoverRepoCard( tint = Color(0xFF4CAF50) ) Text( - "Verified", + stringResource(Res.string.plugin_discover_badge_verified), style = AppTheme.typography.labelSmall, color = Color(0xFF4CAF50) ) @@ -917,7 +924,7 @@ private fun DiscoverRepoCard( ) { MillenniumIcon( Iconsax.IconsaxHeart, - contentDescription = "Github Stars", + contentDescription = stringResource(Res.string.plugin_discover_stars_content_description), modifier = Modifier.size(11.dp), tint = AppTheme.colors.onSurfaceVariant ) @@ -942,7 +949,7 @@ private fun DiscoverRepoCard( ) { MillenniumIcon( Iconsax.CarbonGithubLogo, - contentDescription = "Github Repository URL", + contentDescription = stringResource(Res.string.plugin_discover_repository_content_description), modifier = Modifier.size(10.dp), tint = AppTheme.colors.onSurfaceVariant ) @@ -985,7 +992,7 @@ private fun DetailRow(label: String, value: String) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "$label:", + stringResource(Res.string.plugin_info_label_format, label), style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) @@ -1005,7 +1012,7 @@ private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "$label:", + stringResource(Res.string.plugin_info_label_format, label), style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) @@ -1027,7 +1034,7 @@ private fun DetailChipsRow(label: String, chips: List) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "$label:", + stringResource(Res.string.plugin_info_label_format, label), style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt index 36ff939a..d0e1e0e7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt @@ -66,6 +66,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft import okio.Path import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_version_label import spotube.composeapp.generated.resources.settings_plugins_ability_audio import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics import spotube.composeapp.generated.resources.settings_plugins_ability_metadata @@ -110,8 +111,9 @@ internal fun PluginAbility.displayLabel(): String = when (this) { } /** A short "v1.2.3 · Author" summary shown under a plugin in the picker. */ +@Composable private fun PluginEntry.metadataLabel(): String = buildString { - append("v").append(version) + append(stringResource(Res.string.plugin_version_label, version)) if (author.isNotBlank()) { append(" · ").append(author) } @@ -326,7 +328,7 @@ private fun AbilityPluginTile( text = buildString { append(ability.displayLabel()) if (!version.isNullOrBlank()) { - append(" · v").append(version) + append(" · ").append(stringResource(Res.string.plugin_version_label, version)) } }, style = AppTheme.typography.labelMedium, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt index 84b675ca..f598b5c6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -57,12 +57,20 @@ import dev.krtirtho.spotube.modules.plugin.GitHubRelease import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxBox +import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_action_install +import spotube.composeapp.generated.resources.plugin_install_no_releases +import spotube.composeapp.generated.resources.plugin_install_prerelease +import spotube.composeapp.generated.resources.plugin_install_release_notes +import spotube.composeapp.generated.resources.plugin_install_select_release_label +import spotube.composeapp.generated.resources.plugin_install_select_release_placeholder @Composable fun PluginInstallDialog( @@ -135,13 +143,13 @@ fun PluginInstallDialog( } } else if (releases.isEmpty()) { Text( - "No releases found", + stringResource(Res.string.plugin_install_no_releases), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant ) } else { Text( - "Select Release", + stringResource(Res.string.plugin_install_select_release_label), style = AppTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = AppTheme.colors.onSurface @@ -168,7 +176,8 @@ fun PluginInstallDialog( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - selectedRelease?.tagName ?: "Select a release", + selectedRelease?.tagName + ?: stringResource(Res.string.plugin_install_select_release_placeholder), style = AppTheme.typography.bodyMedium ) MillenniumIcon( @@ -184,14 +193,14 @@ fun PluginInstallDialog( selectedRelease?.let { release -> if (release.prerelease) { Text( - "Pre-release", + stringResource(Res.string.plugin_install_prerelease), style = AppTheme.typography.labelSmall, color = AppTheme.colors.error ) } if (!release.body.isNullOrBlank()) { Text( - "Release Notes", + stringResource(Res.string.plugin_install_release_notes), style = AppTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = AppTheme.colors.onSurface @@ -225,7 +234,7 @@ fun PluginInstallDialog( onClick = { selectedRelease?.let { onInstall(it) } }, enabled = selectedRelease != null && !isLoadingReleases ) { - Text("Install") + Text(stringResource(Res.string.plugin_action_install)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt index 40be3978..5c385c41 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -70,8 +70,8 @@ import spotube.composeapp.generated.resources.plugin_permissions_none import spotube.composeapp.generated.resources.plugin_permissions_requested_title import spotube.composeapp.generated.resources.plugin_permissions_supplied_label import spotube.composeapp.generated.resources.plugin_version_label -import spotube.composeapp.generated.resources.settings_action_cancel -import spotube.composeapp.generated.resources.settings_action_close +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.common_close @Composable fun PluginPermissionDialog( @@ -197,9 +197,9 @@ fun PluginPermissionDialog( style = MillenniumButtonStyle.Secondary) { Text( if (confirmLabel == null) { - stringResource(Res.string.settings_action_close) + stringResource(Res.string.common_close) } else { - stringResource(Res.string.settings_action_cancel) + stringResource(Res.string.common_cancel) } ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 324c464f..2eb73d78 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -34,6 +34,9 @@ import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.liked_tracks +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun SavedTracksScreen( @@ -62,11 +65,11 @@ fun SavedTracksScreen( val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( - title = "Saved Tracks", - description = dataState?.let { "${it.totalCount} tracks" } ?: "", + title = stringResource(Res.string.saved_tracks_title), + description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "", imageURL = "", imageResource = Res.drawable.liked_tracks, - ownerName = "You", + ownerName = stringResource(Res.string.saved_tracks_owner_you), ownerImageURL = null, onOwnerClick = {}, onPlay = viewModel::playSavedTracks, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 44ecc52e..c8bdb3dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -57,6 +57,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks" @@ -171,7 +173,7 @@ class SavedTracksViewModel( totalCount = totalCount) }.onFailure { e -> logger.e(e) { "Failed to load saved tracks" } - _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") + _state.value = SavedTracksScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } @@ -188,7 +190,7 @@ class SavedTracksViewModel( totalCount = currentState.totalCount) }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } - _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") + _state.value = SavedTracksScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } } @@ -367,7 +369,7 @@ fun rememberIsSavedTracks( val result = repository.isSavedTracks(trackIds) state = SavedState.Success(result) }.onFailure { e -> - state = SavedState.Error(e.message ?: "Unknown error") + state = SavedState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt index 5ff73aeb..65af0b10 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt @@ -34,6 +34,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_error_no_metadata_plugin class SearchRepository( private val pluginManager: PluginManager @@ -70,7 +73,7 @@ class SearchRepository( suspend fun loadSupportedSearchTypes(): List { return supportedTypesCache.get(Unit) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.supportedSearchTypes } } @@ -80,7 +83,7 @@ class SearchRepository( suspend fun searchAll(query: String): List { return allSearchCache.get(query) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.search(query) } } @@ -94,7 +97,7 @@ class SearchRepository( val key = query to pagination return trackSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchTracks(query, pagination) } } @@ -108,7 +111,7 @@ class SearchRepository( val key = query to pagination return albumSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchAlbums(query, pagination) } } @@ -122,7 +125,7 @@ class SearchRepository( val key = query to pagination return artistSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchArtists(query, pagination) } } @@ -136,7 +139,7 @@ class SearchRepository( val key = query to pagination return playlistSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchPlaylists(query, pagination) } } @@ -150,7 +153,7 @@ class SearchRepository( val key = query to pagination return userSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchUsers(query, pagination) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 8db81996..24d466c6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -123,6 +123,36 @@ import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.search_clear_history import spotube.composeapp.generated.resources.search_placeholder +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.search_results_title +import spotube.composeapp.generated.resources.search_no_search_types +import spotube.composeapp.generated.resources.search_start_typing +import spotube.composeapp.generated.resources.search_no_results +import spotube.composeapp.generated.resources.search_section_tracks +import spotube.composeapp.generated.resources.search_section_users +import spotube.composeapp.generated.resources.search_start_typing_tracks +import spotube.composeapp.generated.resources.search_loading_tracks +import spotube.composeapp.generated.resources.search_no_albums +import spotube.composeapp.generated.resources.search_loading_albums +import spotube.composeapp.generated.resources.search_no_artists +import spotube.composeapp.generated.resources.search_loading_artists +import spotube.composeapp.generated.resources.search_no_playlists +import spotube.composeapp.generated.resources.search_loading_playlists +import spotube.composeapp.generated.resources.search_no_users +import spotube.composeapp.generated.resources.search_loading_users +import spotube.composeapp.generated.resources.search_see_all +import spotube.composeapp.generated.resources.search_searching +import spotube.composeapp.generated.resources.search_warm_crates +import spotube.composeapp.generated.resources.search_warm_cosmos +import spotube.composeapp.generated.resources.search_warm_speakers +import spotube.composeapp.generated.resources.search_warm_vinyl +import spotube.composeapp.generated.resources.search_warm_music_gods +import spotube.composeapp.generated.resources.search_warm_antennas +import spotube.composeapp.generated.resources.search_warm_records +import spotube.composeapp.generated.resources.search_tab_all +import org.jetbrains.compose.resources.StringResource import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -137,6 +167,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val audioPlayerQueue: AudioPlayerQueue = koinInject() val shareService: ShareService = koinInject() val remotePlaybackController: RemotePlaybackController = koinInject() + val searchResultsTitle = stringResource(Res.string.search_results_title) val downloadsViewModel: DownloadsViewModel = koinViewModel() val libraryRepository: LibraryRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject() @@ -283,7 +314,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } fun bulkAddToQueue(tracks: List) { - remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") + remotePlaybackController.requestTracksAddToQueue(tracks, searchResultsTitle) } fun bulkAddToJam(tracks: List) { @@ -291,7 +322,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } fun bulkPlayNext(tracks: List) { - remotePlaybackController.requestTracksPlayNext(tracks, "Search results") + remotePlaybackController.requestTracksPlayNext(tracks, searchResultsTitle) } Column( @@ -360,7 +391,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { selectedType == null -> { SearchMessage( - message = "No search types available", + message = stringResource(Res.string.search_no_search_types), modifier = Modifier .fillMaxSize() ) @@ -616,7 +647,7 @@ private fun SearchAllTab( modifier: Modifier = Modifier ) { if (query.isBlank()) { - SearchMessage(message = "Start typing to search", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_start_typing), modifier = modifier) return } @@ -644,7 +675,7 @@ private fun SearchAllTab( } if (!hasAnyContent && !isLoadingAll) { - SearchMessage(message = "No results found", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_no_results), modifier = modifier) return } @@ -677,7 +708,7 @@ private fun SearchAllTab( headerContent = { if (showTracks) { Text( - text = "Tracks", + text = stringResource(Res.string.search_section_tracks), style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp) @@ -699,7 +730,7 @@ private fun SearchAllTab( ) { if (MetadataSupportedSearchType.PLAYLIST in supportedSearchTypes && playlists.items.isNotEmpty()) { SearchHorizontalSection( - title = "Playlists", + title = stringResource(Res.string.library_tab_playlists), onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }) { playlists.items.forEach { playlist -> item(key = playlist.id) { PlaylistCard(playlist = playlist) } @@ -709,7 +740,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ALBUM in supportedSearchTypes && albums.items.isNotEmpty()) { SearchHorizontalSection( - title = "Albums", + title = stringResource(Res.string.library_tab_albums), onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }) { albums.items.forEach { album -> item(key = album.id) { AlbumCard(album = album) } @@ -719,7 +750,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ARTIST in supportedSearchTypes && artists.items.isNotEmpty()) { SearchHorizontalSection( - title = "Artists", + title = stringResource(Res.string.library_tab_artists), onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }) { artists.items.forEach { artist -> item(key = artist.id) { ArtistCard(artist = artist) } @@ -729,7 +760,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.USER in supportedSearchTypes && users.items.isNotEmpty()) { SearchHorizontalSection( - title = "Users", + title = stringResource(Res.string.search_section_users), onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }) { users.items.forEach { user -> item(key = user.id) { UserCard(user = user) } @@ -762,12 +793,12 @@ private fun SearchTracksTab( modifier: Modifier = Modifier ) { if (query.isBlank()) { - SearchMessage(message = "Start typing to search tracks", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_start_typing_tracks), modifier = modifier) return } if (tracks.isLoading && tracks.items.isEmpty()) { - SearchLoadingIndicator(message = "Loading tracks...", modifier = modifier) + SearchLoadingIndicator(message = stringResource(Res.string.search_loading_tracks), modifier = modifier) return } @@ -810,8 +841,8 @@ private fun SearchAlbumsTab( error = albums.error, hasNextPage = albums.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No albums found", - loadingMessage = "Loading albums...", + emptyMessage = stringResource(Res.string.search_no_albums), + loadingMessage = stringResource(Res.string.search_loading_albums), modifier = modifier ) { album -> AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) @@ -832,8 +863,8 @@ private fun SearchArtistsTab( error = artists.error, hasNextPage = artists.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No artists found", - loadingMessage = "Loading artists...", + emptyMessage = stringResource(Res.string.search_no_artists), + loadingMessage = stringResource(Res.string.search_loading_artists), modifier = modifier ) { artist -> ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) @@ -854,8 +885,8 @@ private fun SearchPlaylistsTab( error = playlists.error, hasNextPage = playlists.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No playlists found", - loadingMessage = "Loading playlists...", + emptyMessage = stringResource(Res.string.search_no_playlists), + loadingMessage = stringResource(Res.string.search_loading_playlists), modifier = modifier ) { playlist -> PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) @@ -876,8 +907,8 @@ private fun SearchUsersTab( error = users.error, hasNextPage = users.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No users found", - loadingMessage = "Loading users...", + emptyMessage = stringResource(Res.string.search_no_users), + loadingMessage = stringResource(Res.string.search_loading_users), modifier = modifier ) { user -> UserCard(user = user, modifier = Modifier.fillMaxWidth()) @@ -899,7 +930,7 @@ private fun SearchGridTab( itemContent: @Composable (T) -> Unit ) { if (query.isBlank()) { - SearchMessage(message = "Start typing to search", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_start_typing), modifier = modifier) return } @@ -993,7 +1024,7 @@ private fun SearchHorizontalSection( ) Text( - text = "See all", + text = stringResource(Res.string.search_see_all), style = AppTheme.typography.labelLarge, color = AppTheme.colors.primary, modifier = Modifier.clickable(onClick = onSeeAll) @@ -1055,14 +1086,14 @@ private fun SearchTrackRow( } } -private val WarmSearchMessages = listOf( - "Digging through the crates...", - "Searching the cosmos...", - "Warming up the speakers...", - "Dusting off the vinyl...", - "Consulting the music gods...", - "Tuning the antennas...", - "Flipping through the records..." +private val WarmSearchMessages: List = listOf( + Res.string.search_warm_crates, + Res.string.search_warm_cosmos, + Res.string.search_warm_speakers, + Res.string.search_warm_vinyl, + Res.string.search_warm_music_gods, + Res.string.search_warm_antennas, + Res.string.search_warm_records ) @Composable @@ -1102,7 +1133,7 @@ private fun SearchLoadingIndicator( label = "message-cycle" ) val displayMessage = - message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size] + message ?: stringResource(WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size]) Column( modifier = modifier.fillMaxWidth(), @@ -1115,7 +1146,7 @@ private fun SearchLoadingIndicator( ) { MillenniumIcon( imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Searching", + contentDescription = stringResource(Res.string.search_searching), tint = AppTheme.colors.primary, modifier = Modifier .size(48.dp) @@ -1183,10 +1214,10 @@ private fun SearchMessageInline( @Composable private fun MetadataSupportedSearchType.tabTitle(): String = when (this) { - MetadataSupportedSearchType.ALL -> "All" - MetadataSupportedSearchType.TRACK -> "Tracks" - MetadataSupportedSearchType.PLAYLIST -> "Playlists" - MetadataSupportedSearchType.ALBUM -> "Albums" - MetadataSupportedSearchType.ARTIST -> "Artists" - MetadataSupportedSearchType.USER -> "Users" + MetadataSupportedSearchType.ALL -> stringResource(Res.string.search_tab_all) + MetadataSupportedSearchType.TRACK -> stringResource(Res.string.search_section_tracks) + MetadataSupportedSearchType.PLAYLIST -> stringResource(Res.string.library_tab_playlists) + MetadataSupportedSearchType.ALBUM -> stringResource(Res.string.library_tab_albums) + MetadataSupportedSearchType.ARTIST -> stringResource(Res.string.library_tab_artists) + MetadataSupportedSearchType.USER -> stringResource(Res.string.search_section_users) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt index 44b0b425..93ce9c70 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt @@ -41,6 +41,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_error_capabilities +import spotube.composeapp.generated.resources.search_error_search +import spotube.composeapp.generated.resources.search_error_tracks +import spotube.composeapp.generated.resources.search_error_albums +import spotube.composeapp.generated.resources.search_error_artists +import spotube.composeapp.generated.resources.search_error_playlists +import spotube.composeapp.generated.resources.search_error_users data class SearchPagedState( val items: List = emptyList(), @@ -243,7 +252,7 @@ class SearchScreenViewModel( isLoadingSearchTypes = false, supportedSearchTypes = emptyList(), selectedSearchType = null, - tracks = SearchPagedState(error = it.message ?: "Failed to load search capabilities")) + tracks = SearchPagedState(error = it.message ?: getString(Res.string.search_error_capabilities))) } } } @@ -324,12 +333,13 @@ class SearchScreenViewModel( hasNextPage = false, isLoading = false)) }.onFailure { + val message = it.message ?: getString(Res.string.search_error_search) _state.value = _state.value.copy( - tracks = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - playlists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - albums = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - artists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - users = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search")) + tracks = SearchPagedState(isLoading = false, error = message), + playlists = SearchPagedState(isLoading = false, error = message), + albums = SearchPagedState(isLoading = false, error = message), + artists = SearchPagedState(isLoading = false, error = message), + users = SearchPagedState(isLoading = false, error = message)) } return@coroutineScope } @@ -386,7 +396,7 @@ class SearchScreenViewModel( tracks = _state.value.tracks.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search tracks") + error = throwable.message ?: getString(Res.string.search_error_tracks)) ) } } @@ -426,7 +436,7 @@ class SearchScreenViewModel( albums = _state.value.albums.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search albums") + error = throwable.message ?: getString(Res.string.search_error_albums)) ) } } @@ -466,7 +476,7 @@ class SearchScreenViewModel( artists = _state.value.artists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search artists") + error = throwable.message ?: getString(Res.string.search_error_artists)) ) } } @@ -506,7 +516,7 @@ class SearchScreenViewModel( playlists = _state.value.playlists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search playlists") + error = throwable.message ?: getString(Res.string.search_error_playlists)) ) } } @@ -546,7 +556,7 @@ class SearchScreenViewModel( users = _state.value.users.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search users") + error = throwable.message ?: getString(Res.string.search_error_users)) ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt index 411935f7..eeaaf81e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt @@ -54,6 +54,10 @@ import spotube.composeapp.generated.resources.settings_jam_section_diagnostics import spotube.composeapp.generated.resources.settings_jam_section_identity import spotube.composeapp.generated.resources.settings_jam_broker_username import spotube.composeapp.generated.resources.settings_section_jam +import spotube.composeapp.generated.resources.settings_jam_broker_content_description +import spotube.composeapp.generated.resources.settings_jam_broker_username_placeholder +import spotube.composeapp.generated.resources.settings_jam_broker_test_fail +import spotube.composeapp.generated.resources.jam_error_connection_failed @Composable fun JamSettingsScreen( @@ -98,7 +102,7 @@ internal fun JamSettingsContent( title = stringResource(Res.string.settings_jam_broker_host), value = broker.host, placeholder = "broker.example.com", - icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") }, + icon = { SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_jam_broker_content_description)) }, onValueSaved = { host -> viewModel.update { copy( @@ -160,7 +164,7 @@ internal fun JamSettingsContent( TextInputSettingCard( title = stringResource(Res.string.settings_jam_broker_username), value = broker.username.orEmpty(), - placeholder = "anonymous", + placeholder = stringResource(Res.string.settings_jam_broker_username_placeholder), onValueSaved = { username -> viewModel.update { copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) @@ -218,7 +222,11 @@ internal fun JamSettingsContent( val canTest = broker.host.isNotBlank() && !uiState.isTesting val resultMessage = uiState.testResult?.fold( onSuccess = { it }, - onFailure = { "Failed: ${it.message ?: "Connection failed"}" }) + onFailure = { + stringResource( + Res.string.settings_jam_broker_test_fail, + it.message ?: stringResource(Res.string.jam_error_connection_failed)) + }) SettingCardItem( enabled = canTest, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt index 2c1d3d3b..9db74b04 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt @@ -279,20 +279,50 @@ enum class CountryCode(val code: String, val displayName: String) { } } +/** + * App UI languages. Each one (except [SYSTEM]) must have a matching + * `composeResources/values-/strings.xml`. [displayName] is the language's own name + * (endonym), so it is intentionally not localized. + */ @Serializable enum class SupportedLanguages( val locale: String, val country: String, - val displayName: String + val displayName: String, + val isRtl: Boolean = false ) { - EN("en", "US", "English"), - BN("bn", "BD", "Bengali"); + /** Follow the device/OS language. */ + SYSTEM("", "", ""), + EN("en", "", "English"), + AR("ar", "", "العربية", isRtl = true), + BN("bn", "", "বাংলা"), + DE("de", "", "Deutsch"), + ES("es", "", "Español"), + FA("fa", "", "فارسی", isRtl = true), + FR("fr", "", "Français"), + HI("hi", "", "हिन्दी"), + ID("id", "", "Bahasa Indonesia"), + IT("it", "", "Italiano"), + JA("ja", "", "日本語"), + KO("ko", "", "한국어"), + NL("nl", "", "Nederlands"), + PL("pl", "", "Polski"), + PT_BR("pt", "BR", "Português (Brasil)"), + RU("ru", "", "Русский"), + TH("th", "", "ไทย"), + TR("tr", "", "Türkçe"), + UK("uk", "", "Українська"), + VI("vi", "", "Tiếng Việt"), + ZH_CN("zh", "CN", "简体中文"), + ZH_TW("zh", "TW", "繁體中文"); - companion object { - fun fromLocale(locale: String): SupportedLanguages? { - return entries.find { it.locale == locale } + /** BCP 47 tag (e.g. "zh-TW"), or null for [SYSTEM]. */ + val languageTag: String? + get() = when { + this == SYSTEM -> null + country.isEmpty() -> locale + else -> "$locale-$country" } - } } @Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index 8d01591d..a43443f8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.settings import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.spotube.millennium.theme.MillenniumPalette @@ -30,7 +31,10 @@ enum class Theme { @Serializable data class UserSettings( // Language and Region - val language: SupportedLanguages = SupportedLanguages.EN, + // Stored under a new name: the old "language" field was persisted as EN for everyone while + // it had no effect, so reading it would force English on existing users. + @SerialName("appLanguage") + val language: SupportedLanguages = SupportedLanguages.SYSTEM, val country: CountryCode = CountryCode.BD, // Appearance diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt index ec1c5649..40095a64 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt @@ -93,6 +93,7 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_close import spotube.composeapp.generated.resources.settings_blacklist_title import spotube.composeapp.generated.resources.settings_screen_title import spotube.composeapp.generated.resources.settings_section_about @@ -211,7 +212,7 @@ private fun SettingsPane( style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Close") + contentDescription = stringResource(Res.string.common_close)) } } MillenniumSeparator() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index a5b9d8ac..abdecb8c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -161,7 +161,7 @@ internal fun SelectionSettingCard( onClick = { isDialogOpen = false }, style = MillenniumButtonStyle.Quiet ) { - Text(stringResource(Res.string.settings_action_close)) + Text(stringResource(Res.string.common_close)) } } } @@ -267,13 +267,13 @@ internal fun TextInputSettingCard( }, enabled = errorMessage == null ) { - Text(stringResource(Res.string.settings_action_save)) + Text(stringResource(Res.string.common_save)) } MillenniumButton( onClick = { isDialogOpen = false }, style = MillenniumButtonStyle.Secondary ) { - Text(stringResource(Res.string.settings_action_cancel)) + Text(stringResource(Res.string.common_cancel)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt index 581a2dcf..ea4b2b5c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -180,10 +180,10 @@ internal fun LocalMediaFoldersEditorContent( enabled = !state.isSaving, style = MillenniumButtonStyle.Secondary ) { - Text(stringResource(Res.string.settings_action_cancel)) + Text(stringResource(Res.string.common_cancel)) } MillenniumButton(onClick = onSave, enabled = state.canSave) { - Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.settings_action_save)) + Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.common_save)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index 19db935a..7337ad09 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -182,12 +182,12 @@ private fun AppearanceSettingCard( onSaved(draftPalette) isDialogOpen = false }) { - MillenniumText(stringResource(Res.string.settings_action_save)) + MillenniumText(stringResource(Res.string.common_save)) } MillenniumButton( onClick = { isDialogOpen = false }, style = MillenniumButtonStyle.Secondary) { - MillenniumText(stringResource(Res.string.settings_action_cancel)) + MillenniumText(stringResource(Res.string.common_cancel)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt index a4795ff1..7d1a1eb1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.Globe import compose.icons.feathericons.MapPin @@ -46,7 +47,7 @@ internal fun LazyListScope.languageRegionSection( title = stringResource(Res.string.settings_language_title), subtitle = stringResource( Res.string.settings_language_subtitle_current, - settings.language.displayName + languageLabel(settings.language) ), icon = { SettingsItemIcon( @@ -56,9 +57,7 @@ internal fun LazyListScope.languageRegionSection( }, selectedOption = settings.language, options = SupportedLanguages.entries, - optionLabel = { - stringResource(Res.string.settings_option_name_and_code, it.displayName, it.locale) - }, + optionLabel = { languageLabel(it) }, onOptionSelected = { value -> settingsViewModel.updateSettings { copy(language = value) @@ -94,3 +93,11 @@ internal fun LazyListScope.languageRegionSection( ) } +@Composable +private fun languageLabel(language: SupportedLanguages): String = + if (language == SupportedLanguages.SYSTEM) { + stringResource(Res.string.settings_language_system_default) + } else { + stringResource(Res.string.settings_option_name_and_code, language.displayName, language.languageTag!!) + } + diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt index affcb156..dbbe2b26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt @@ -45,6 +45,10 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_audio_format_label +import spotube.composeapp.generated.resources.settings_audio_quality_lossless +import spotube.composeapp.generated.resources.settings_audio_quality_lossy private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 44_000), @@ -156,8 +160,9 @@ internal fun availableAudioQualities(format: AudioFormat, current: AudioQuality) return options } +@Composable internal fun AudioFormat.displayLabel(): String { - return "${codec.uppercase()} in ${container.uppercase()}" + return stringResource(Res.string.settings_audio_format_label, codec.uppercase(), container.uppercase()) } internal fun AudioFormat.preferredQuality(): AudioQuality { @@ -168,16 +173,17 @@ internal fun AudioFormat.resolveQuality(preferred: AudioQuality): AudioQuality { return qualities.firstOrNull { it == preferred } ?: preferredQuality() } +@Composable internal fun AudioQuality.displayLabel(): String { return when (this) { - is AudioQuality.Lossy -> "${bitrate / 1000} kbps" + is AudioQuality.Lossy -> stringResource(Res.string.settings_audio_quality_lossy, bitrate / 1000) is AudioQuality.Lossless -> { val sampleRateLabel = if (sampleRate % 1000 == 0) { "${sampleRate / 1000}" } else { "${sampleRate / 1000.0}" } - "$sampleRateLabel kHz • $channels ch" + stringResource(Res.string.settings_audio_quality_lossless, sampleRateLabel, channels) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt index 96b7f4c7..113642fb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt @@ -25,6 +25,7 @@ import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.krtirtho.spotube.NavigationItem +import org.jetbrains.compose.resources.stringResource import dev.krtirtho.spotube.bottombarTabs import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator @@ -42,10 +43,11 @@ fun AppBottombar( bottombarTabs.filterIsInstance>() .forEach { tab -> val selected = navigationState.topLevelRoute == tab.route + val title = stringResource(tab.title) MillenniumBottomNavigationItem( selected = selected, onClick = { navigator.navigate(tab.route) }, - contentDescription = tab.title, + contentDescription = title, icon = { MillenniumIcon( imageVector = tab.icon, @@ -53,7 +55,7 @@ fun AppBottombar( tint = LocalTextStyle.current.color ) }, - label = { Text(tab.title) }, + label = { Text(title) }, badge = if (tab.route == Routes.Library) { libraryBadge } else { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 75de7d32..f869f920 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -127,6 +127,38 @@ import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.downloads_status_cancelled +import spotube.composeapp.generated.resources.downloads_status_failed +import spotube.composeapp.generated.resources.downloads_status_queued +import spotube.composeapp.generated.resources.player_alternative_source +import spotube.composeapp.generated.resources.player_collapse_content_description +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_download +import spotube.composeapp.generated.resources.player_download_already_downloaded +import spotube.composeapp.generated.resources.player_download_retrying +import spotube.composeapp.generated.resources.player_download_started +import spotube.composeapp.generated.resources.player_download_status_downloaded +import spotube.composeapp.generated.resources.player_download_status_downloading +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_expand_lyrics_content_description +import spotube.composeapp.generated.resources.player_go_to_album +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_lyrics +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_now_playing +import spotube.composeapp.generated.resources.player_options_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_track_details +import spotube.composeapp.generated.resources.queue_title // The expanded player on small screens // on the top left it has a angle/chevron down that can be used to collapse the player back to the @@ -270,7 +302,7 @@ fun AppExpandedPlayer( item { OptionTile( icon = Iconsax.SwapHorizontal2, - label = "Alternative Source", + label = stringResource(Res.string.player_alternative_source), onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() onAlternativeSource() @@ -280,12 +312,12 @@ fun AppExpandedPlayer( val download = currentDownload val status = download?.status val statusLabel = when (status) { - is DownloadStatus.Completed -> "Downloaded" - is DownloadStatus.Failed -> "Failed" - is DownloadStatus.Cancelled -> "Cancelled" - is DownloadStatus.Queued -> "Queued" - is DownloadStatus.Downloading -> "Downloading..." - null -> "Download" + is DownloadStatus.Completed -> stringResource(Res.string.player_download_status_downloaded) + is DownloadStatus.Failed -> stringResource(Res.string.downloads_status_failed) + is DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled) + is DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued) + is DownloadStatus.Downloading -> stringResource(Res.string.player_download_status_downloading) + null -> stringResource(Res.string.player_download) } val statusColor = when (status) { is DownloadStatus.Completed -> AppTheme.colors.primary @@ -308,14 +340,14 @@ fun AppExpandedPlayer( when (status) { is DownloadStatus.Completed -> { scope.launch { - snackbarHostState.showSnackbar("Already downloaded") + snackbarHostState.showSnackbar(getString(Res.string.player_download_already_downloaded)) } } is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { download?.let { downloadsViewModel.retry(it.id) } scope.launch { - snackbarHostState.showSnackbar("Retrying download...") + snackbarHostState.showSnackbar(getString(Res.string.player_download_retrying)) } } @@ -329,7 +361,7 @@ fun AppExpandedPlayer( downloadsViewModel.downloadTrack(track) scope.launch { snackbarHostState.showSnackbar( - message = "Downloading ${track.title}" + message = getString(Res.string.player_download_started, track.title) ) } } @@ -348,7 +380,7 @@ fun AppExpandedPlayer( DownloadProgressIcon( track = currentTrack, icon = Iconsax.IconsaxDirectboxReceive, - contentDescription = "Download", + contentDescription = stringResource(Res.string.player_download), modifier = Modifier.size(28.dp), tint = AppTheme.colors.onSurface ) @@ -386,7 +418,7 @@ fun AppExpandedPlayer( item { OptionTile( icon = Iconsax.InconsaxClock, - label = "Sleep Timer", + label = stringResource(Res.string.player_sleep_timer), onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() playerOptionsViewModel.showSleepTimerDialog() @@ -396,7 +428,7 @@ fun AppExpandedPlayer( item { OptionTile( icon = Iconsax.IconsaxCd, - label = "Go to Album", + label = stringResource(Res.string.player_go_to_album), onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() val albumId = currentTrack?.album?.id @@ -409,7 +441,7 @@ fun AppExpandedPlayer( item { OptionTile( icon = Iconsax.Iconsax3DotsMore, - label = "Track Details", + label = stringResource(Res.string.player_track_details), onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() playerOptionsViewModel.showTrackDetails() @@ -439,11 +471,11 @@ fun AppExpandedPlayer( ) { MillenniumIcon( Iconsax.IconsaxArrowDown4, - contentDescription = "Collapse player" + contentDescription = stringResource(Res.string.player_collapse_content_description) ) } Text( - text = "Now Playing", + text = stringResource(Res.string.player_now_playing), style = AppTheme.typography.titleMedium, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, @@ -453,7 +485,7 @@ fun AppExpandedPlayer( onClick = playerOptionsViewModel::showMoreOptionsSheet, style = MillenniumIconButtonStyle.Quiet ) { - MillenniumIcon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") + MillenniumIcon(Iconsax.Iconsax3DotsMore, contentDescription = stringResource(Res.string.player_options_content_description)) } } @@ -516,7 +548,7 @@ fun AppExpandedPlayer( onClick = onQueue, style = MillenniumIconButtonStyle.Secondary ) { - MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title)) } } @@ -532,7 +564,7 @@ fun AppExpandedPlayer( onSeekFinished() }, enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, - label = "Seek", + label = stringResource(Res.string.player_seek_label), modifier = Modifier.fillMaxWidth() ) Row( @@ -567,7 +599,7 @@ fun AppExpandedPlayer( ) { MillenniumIcon( Iconsax.IconsaxShuffle, - contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", + contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), tint = if (playerUiState.isShuffling) { AppTheme.colors.primary } else { @@ -580,7 +612,7 @@ fun AppExpandedPlayer( enabled = !isJamGuest, style = MillenniumIconButtonStyle.Quiet ) { - MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) } MillenniumIconButton( onClick = ::onPlayPause, @@ -588,7 +620,7 @@ fun AppExpandedPlayer( ) { MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description), modifier = Modifier.size(30.dp), tint = LocalTextStyle.current.color ) @@ -598,7 +630,7 @@ fun AppExpandedPlayer( enabled = !isJamGuest, style = MillenniumIconButtonStyle.Quiet ) { - MillenniumIcon(Iconsax.IconsaxNext, contentDescription = "Next") + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) } MillenniumIconButton( onClick = ::onLoopToggle, @@ -611,7 +643,7 @@ fun AppExpandedPlayer( LoopState.ONE -> Iconsax.IconsaxRepeateOne LoopState.ALL -> Iconsax.IconsaxRepeatMusic }, - contentDescription = "Loop mode ${playerUiState.loopState.name}", + contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), tint = if (playerUiState.loopState == LoopState.NONE) { AppTheme.colors.onSurfaceVariant } else { @@ -708,7 +740,7 @@ private fun LyricsPreviewCard( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Lyrics", + text = stringResource(Res.string.player_lyrics), style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant ) @@ -718,7 +750,7 @@ private fun LyricsPreviewCard( ) { MillenniumIcon( Iconsax.IconsaxArrowSquareUp, - contentDescription = "Expand lyrics", + contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), tint = AppTheme.colors.onSurfaceVariant ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 0b034497..1586083d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -103,6 +103,24 @@ import spotube.composeapp.generated.resources.player_go_to_album import spotube.composeapp.generated.resources.player_sleep_timer import spotube.composeapp.generated.resources.player_track_details import kotlin.time.Duration.Companion.milliseconds +import spotube.composeapp.generated.resources.player_alternative_source_content_description +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_download +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_lyrics +import spotube.composeapp.generated.resources.player_more_options_content_description +import spotube.composeapp.generated.resources.player_mute_content_description +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_or_pause_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.player_unmute_content_description +import spotube.composeapp.generated.resources.player_volume_label +import spotube.composeapp.generated.resources.queue_title // The main player on large screens shown at the bottom of the screen permanently. It should be // below the sidebar. And should be similarly give a floating vibe with a translucent background @@ -288,7 +306,7 @@ fun AppLargePlayer( onSeekFinished() }, enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, - label = "Seek", + label = stringResource(Res.string.player_seek_label), modifier = Modifier.fillMaxWidth() ) Row( @@ -319,7 +337,7 @@ fun AppLargePlayer( ) { MillenniumIcon( Iconsax.IconsaxShuffle, - contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", + contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), tint = if (playerUiState.isShuffling) { AppTheme.colors.primary } else { @@ -332,7 +350,7 @@ fun AppLargePlayer( enabled = !isJamGuest, style = MillenniumIconButtonStyle.Quiet ) { - MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) } MillenniumIconButton( onClick = ::onPlayPause, @@ -340,7 +358,7 @@ fun AppLargePlayer( ) { MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description), tint = LocalTextStyle.current.color ) } @@ -349,7 +367,7 @@ fun AppLargePlayer( enabled = !isJamGuest, style = MillenniumIconButtonStyle.Quiet ) { - MillenniumIcon(Iconsax.IconsaxNext, contentDescription = "Next") + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) } MillenniumIconButton( onClick = ::onLoopToggle, @@ -362,7 +380,7 @@ fun AppLargePlayer( LoopState.ONE -> Iconsax.IconsaxRepeateOne LoopState.ALL -> Iconsax.IconsaxRepeatMusic }, - contentDescription = "Loop mode ${playerUiState.loopState.name}", + contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), tint = if (playerUiState.loopState == LoopState.NONE) { AppTheme.colors.onSurfaceVariant } else { @@ -381,7 +399,7 @@ fun AppLargePlayer( MillenniumIconButton( style = MillenniumIconButtonStyle.Quiet, onClick = onQueue ) { - MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title)) } MillenniumIconButton( style = MillenniumIconButtonStyle.Quiet, @@ -394,7 +412,7 @@ fun AppLargePlayer( DownloadProgressIcon( track = (currentEntry as? QueueEntry.StreamingTrack)?.track, icon = Iconsax.IconsaxDirectboxReceive, - contentDescription = "Download" + contentDescription = stringResource(Res.string.player_download) ) } MillenniumIconButton( @@ -402,13 +420,13 @@ fun AppLargePlayer( ) { MillenniumIcon( Iconsax.SwapHorizontal2, - contentDescription = "Alternative source" + contentDescription = stringResource(Res.string.player_alternative_source_content_description) ) } MillenniumIconButton( style = MillenniumIconButtonStyle.Quiet, onClick = onLyrics ) { - MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") + MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = stringResource(Res.string.player_lyrics)) } val goToAlbumLabel = stringResource(Res.string.player_go_to_album) val sleepTimerLabel = stringResource(Res.string.player_sleep_timer) @@ -450,7 +468,7 @@ fun AppLargePlayer( ) { MillenniumIcon( Iconsax.Iconsax3DotsMore, - contentDescription = "More options" + contentDescription = stringResource(Res.string.player_more_options_content_description) ) } }) @@ -476,7 +494,7 @@ fun AppLargePlayer( } MillenniumIcon( imageVector = volumeIcon, - contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute" + contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description) ) } MillenniumSlider( @@ -487,7 +505,7 @@ fun AppLargePlayer( audioPlayer.setVolume(it) } }, - label = "Volume", + label = stringResource(Res.string.player_volume_label), modifier = Modifier.fillMaxWidth(0.62f) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt index 06fcf1ef..3141f1dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt @@ -27,6 +27,11 @@ import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_fallback_artists +import spotube.composeapp.generated.resources.player_fallback_title +import spotube.composeapp.generated.resources.player_unknown_album internal data class PlayerArtist( val id: String?, @@ -89,14 +94,17 @@ internal fun rememberPlayerUiState( val isShuffling by audioPlayer.shuffleModeFlow.collectAsState() val volume by audioPlayer.volumeFlow.collectAsState() val metadataDuration = (currentQueueEntry?.durationInMilliseconds() ?: 0L).milliseconds + val fallbackTitle = stringResource(Res.string.player_fallback_title) + val fallbackArtists = stringResource(Res.string.player_fallback_artists) + val unknownAlbum = stringResource(Res.string.player_unknown_album) val state = PlayerUiState( queue = queue, currentQueueEntry = currentQueueEntry, - title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" }, - artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" }, + title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { fallbackTitle }, + artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists }, artistList = currentQueueEntry?.displayArtistList().orEmpty(), - album = currentQueueEntry?.displayAlbum().orEmpty(), + album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(), coverUrl = currentQueueEntry?.coverUrl().orEmpty(), position = position, actualDuration = duration, @@ -134,9 +142,9 @@ private fun QueueEntry.displayArtistList(): List { } } -private fun QueueEntry.displayAlbum(): String { +private fun QueueEntry.displayAlbum(unknownAlbum: String): String { return when (this) { - is QueueEntry.StreamingTrack -> track.album?.title ?: "Unknown Album" + is QueueEntry.StreamingTrack -> track.album?.title ?: unknownAlbum is QueueEntry.LocalTrack -> album.orEmpty() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 9adcd54f..e89b40bb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -72,6 +72,7 @@ import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.cookie_regular import spotube.composeapp.generated.resources.settings_screen_title import spotube.composeapp.generated.resources.support_button_label +import spotube.composeapp.generated.resources.shell_sidebar_toggle_content_description @Composable fun AppSidebar( @@ -111,7 +112,7 @@ fun AppSidebar( MillenniumSidebarTrigger { MillenniumIconButton( if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken, - contentDescription = "Toggle Sidebar", + contentDescription = stringResource(Res.string.shell_sidebar_toggle_content_description), style = MillenniumIconButtonStyle.Quiet, onClick = { it.toggle() } ) @@ -124,13 +125,13 @@ fun AppSidebar( sidebarTabs.forEach { entry -> when (entry) { is NavigationItem.Group<*> -> { - val isGroupExpanded = groupExpansion[entry.title] ?: true + val isGroupExpanded = groupExpansion[entry.title.key] ?: true MillenniumSidebarGroup( expanded = isGroupExpanded, collapsible = true, groupSpacing = 0.dp, - onExpandedChange = { groupExpansion[entry.title] = it }) { - MillenniumSidebarGroupLabel(entry.title) + onExpandedChange = { groupExpansion[entry.title.key] = it }) { + MillenniumSidebarGroupLabel(stringResource(entry.title)) MillenniumSidebarGroupContent( contentSpacing = 0.dp ) { @@ -190,11 +191,12 @@ private fun SidebarNavItem( selected: Boolean, onClick: () -> Unit ) { + val title = stringResource(item.title) MillenniumSidebarItem( - label = item.title, + label = title, onClick = onClick, selected = selected, - contentDescription = item.title, + contentDescription = title, icon = item.icon ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt index ea66bb29..34397563 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt @@ -37,6 +37,10 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_like_content_description +import spotube.composeapp.generated.resources.player_unlike_content_description @Composable fun PlayerHeartButton( @@ -74,7 +78,7 @@ fun PlayerHeartButton( style = MillenniumIconButtonStyle.Secondary) { MillenniumIcon( imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, - contentDescription = if (isLiked) "Unlike" else "Like", + contentDescription = stringResource(if (isLiked) Res.string.player_unlike_content_description else Res.string.player_like_content_description), tint = if (isLiked) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt index 99fd900f..3529f6ab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt @@ -54,6 +54,50 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.core.server.CacheEntry +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.media_artist +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_sleep_timer_cancel +import spotube.composeapp.generated.resources.player_sleep_timer_description +import spotube.composeapp.generated.resources.player_sleep_timer_minutes_label +import spotube.composeapp.generated.resources.player_sleep_timer_minutes_placeholder +import spotube.composeapp.generated.resources.player_sleep_timer_remaining +import spotube.composeapp.generated.resources.player_sleep_timer_start +import spotube.composeapp.generated.resources.player_track_details +import spotube.composeapp.generated.resources.player_track_details_active_url +import spotube.composeapp.generated.resources.player_track_details_album +import spotube.composeapp.generated.resources.player_track_details_album_genres +import spotube.composeapp.generated.resources.player_track_details_album_id +import spotube.composeapp.generated.resources.player_track_details_album_release_date +import spotube.composeapp.generated.resources.player_track_details_album_track_count +import spotube.composeapp.generated.resources.player_track_details_album_type +import spotube.composeapp.generated.resources.player_track_details_available_streams +import spotube.composeapp.generated.resources.player_track_details_confidence +import spotube.composeapp.generated.resources.player_track_details_disc_number +import spotube.composeapp.generated.resources.player_track_details_duration +import spotube.composeapp.generated.resources.player_track_details_explicit +import spotube.composeapp.generated.resources.player_track_details_external_uri +import spotube.composeapp.generated.resources.player_track_details_id +import spotube.composeapp.generated.resources.player_track_details_isrc +import spotube.composeapp.generated.resources.player_track_details_loading_source +import spotube.composeapp.generated.resources.player_track_details_loading_streams +import spotube.composeapp.generated.resources.player_track_details_matched_source +import spotube.composeapp.generated.resources.player_track_details_matches_preference +import spotube.composeapp.generated.resources.player_track_details_metadata_track +import spotube.composeapp.generated.resources.player_track_details_no_source +import spotube.composeapp.generated.resources.player_track_details_no_streams +import spotube.composeapp.generated.resources.player_track_details_popularity +import spotube.composeapp.generated.resources.player_track_details_selected_stream_content_description +import spotube.composeapp.generated.resources.player_track_details_served_from_cache +import spotube.composeapp.generated.resources.player_track_details_served_from_cache_description +import spotube.composeapp.generated.resources.player_track_details_source_not_needed +import spotube.composeapp.generated.resources.player_track_details_stream_mismatch +import spotube.composeapp.generated.resources.player_track_details_thumbnail_urls +import spotube.composeapp.generated.resources.player_track_details_title +import spotube.composeapp.generated.resources.player_track_details_track_number +import spotube.composeapp.generated.resources.common_close @Composable fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) { @@ -87,16 +131,16 @@ private fun SleepTimerDialog( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = "Sleep timer") { + title = stringResource(Res.string.player_sleep_timer)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (remainingMs != null) { Text( - text = "Playback will pause in ${formatRemainingTime(remainingMs)}.", + text = stringResource(Res.string.player_sleep_timer_remaining, formatRemainingTime(remainingMs)), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.primary) } else { Text( - text = "Pause playback after the selected number of minutes.", + text = stringResource(Res.string.player_sleep_timer_description), style = AppTheme.typography.bodyMedium) } val minutesState = rememberTextFieldState(minutes) @@ -105,23 +149,23 @@ private fun SleepTimerDialog( } MillenniumTextField( state = minutesState, - label = "Minutes", - placeholder = "Choose between 1 and 600 minutes", + label = stringResource(Res.string.player_sleep_timer_minutes_label), + placeholder = stringResource(Res.string.player_sleep_timer_minutes_placeholder), modifier = Modifier.fillMaxWidth()) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (remainingMs != null) { MillenniumButton( onClick = onCancelTimer, - style = MillenniumButtonStyle.Secondary) { Text("Cancel timer") } + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.player_sleep_timer_cancel)) } } MillenniumButton( onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { Text("Close") } + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } MillenniumButton( onClick = onStart, enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) { - Text("Start") + Text(stringResource(Res.string.player_sleep_timer_start)) } } } @@ -135,7 +179,7 @@ private fun TrackDetailsDialog( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = "Track details") { + title = stringResource(Res.string.player_track_details)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( details.track.title, @@ -161,7 +205,7 @@ private fun TrackDetailsDialog( background = SolidColor(AppTheme.colors.tertiaryContainer), shape = RoundedCornerShape(12.dp))) { Text( - "The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request.", + stringResource(Res.string.player_track_details_stream_mismatch), modifier = Modifier.padding(12.dp), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onTertiaryContainer) @@ -171,43 +215,43 @@ private fun TrackDetailsDialog( TrackMetadataSection(details.track) Text( - "Matched audio source", + stringResource(Res.string.player_track_details_matched_source), style = AppTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) val source = details.matchedSource if (details.isLoading) { - Text("Loading matched source…", style = AppTheme.typography.bodySmall) + Text(stringResource(Res.string.player_track_details_loading_source), style = AppTheme.typography.bodySmall) } else if (source == null) { Text( - details.error ?: if (details.localCacheEntry != null) { - "A matched remote source is not needed while playing the local cached file." + details.error ?: details.errorRes?.let { stringResource(it) } ?: if (details.localCacheEntry != null) { + stringResource(Res.string.player_track_details_source_not_needed) } else { - "No matched source is available." + stringResource(Res.string.player_track_details_no_source) }, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) } else { - DetailRow("Title", source.title) - DetailRow("ID", source.id) - DetailRow("Artist", source.artist) - DetailRow("Album", source.album) - DetailRow("Confidence", "${(source.confidence * 100).toInt()}%") - DetailRow("External URI", source.externalUri) + DetailRow(stringResource(Res.string.player_track_details_title), source.title) + DetailRow(stringResource(Res.string.player_track_details_id), source.id) + DetailRow(stringResource(Res.string.media_artist), source.artist) + DetailRow(stringResource(Res.string.player_track_details_album), source.album) + DetailRow(stringResource(Res.string.player_track_details_confidence), "${(source.confidence * 100).toInt()}%") + DetailRow(stringResource(Res.string.player_track_details_external_uri), source.externalUri) } Text( - "Available streams", + stringResource(Res.string.player_track_details_available_streams), style = AppTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) when { details.localCacheEntry != null && details.streams.isEmpty() -> Text( - "This track is being served from the local cache. Stream variants are not used for this playback.", + stringResource(Res.string.player_track_details_served_from_cache_description), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) - details.isLoading -> Text("Loading streams…", style = AppTheme.typography.bodySmall) + details.isLoading -> Text(stringResource(Res.string.player_track_details_loading_streams), style = AppTheme.typography.bodySmall) details.streams.isEmpty() -> Text( - details.error ?: "No streams are available for this source.", + details.error ?: details.errorRes?.let { stringResource(it) } ?: stringResource(Res.string.player_track_details_no_streams), style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant) @@ -244,7 +288,7 @@ private fun TrackDetailsDialog( if (isSelected) { MillenniumIcon( imageVector = Iconsax.IconsaxCheckCircle, - contentDescription = "Currently selected stream", + contentDescription = stringResource(Res.string.player_track_details_selected_stream_content_description), tint = AppTheme.colors.primary) } } @@ -258,7 +302,7 @@ private fun TrackDetailsDialog( color = AppTheme.colors.onSurfaceVariant) if (isSelected) { Text( - "Matches your streaming format & quality preference", + stringResource(Res.string.player_track_details_matches_preference), style = AppTheme.typography.labelSmall, color = AppTheme.colors.primary) } @@ -268,12 +312,12 @@ private fun TrackDetailsDialog( } details.activeStreamUrl?.let { activeUrl -> - DetailRow("Active playback URL", activeUrl) + DetailRow(stringResource(Res.string.player_track_details_active_url), activeUrl) } MillenniumButton( onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { Text("Close") } + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } } } } @@ -296,7 +340,7 @@ private fun LocalCacheBanner(entry: CacheEntry) { tint = AppTheme.colors.onSecondaryContainer) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - "Served from local cache", + stringResource(Res.string.player_track_details_served_from_cache), style = AppTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = AppTheme.colors.onSecondaryContainer) @@ -312,26 +356,26 @@ private fun LocalCacheBanner(entry: CacheEntry) { @Composable private fun TrackMetadataSection(track: MetadataTrack) { Text( - "Metadata track", + stringResource(Res.string.player_track_details_metadata_track), style = AppTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - DetailRow("ID", track.id) - DetailRow("Title", track.title) - DetailRow("Artists", track.artists.joinToString { it.name }) - DetailRow("Album", track.album?.title) - DetailRow("Album ID", track.album?.id) - DetailRow("Album type", track.album?.albumType?.name) - DetailRow("Album release date", track.album?.releaseDate) - DetailRow("Album track count", track.album?.trackCount?.toString()) - DetailRow("Album genres", track.album?.genres?.joinToString()) - DetailRow("Duration", formatRemainingTime(track.durationMs)) - DetailRow("Track number", track.trackNumber?.toString()) - DetailRow("Disc number", track.discNumber?.toString()) - DetailRow("Explicit", track.explicit?.toString()) - DetailRow("Popularity", track.popularity?.toString()) - DetailRow("ISRC", track.isrcCode) - DetailRow("External URI", track.externalUri) - DetailRow("Thumbnail URLs", track.thumbnails?.joinToString { it.url }) + DetailRow(stringResource(Res.string.player_track_details_id), track.id) + DetailRow(stringResource(Res.string.player_track_details_title), track.title) + DetailRow(stringResource(Res.string.library_tab_artists), track.artists.joinToString { it.name }) + DetailRow(stringResource(Res.string.player_track_details_album), track.album?.title) + DetailRow(stringResource(Res.string.player_track_details_album_id), track.album?.id) + DetailRow(stringResource(Res.string.player_track_details_album_type), track.album?.albumType?.name) + DetailRow(stringResource(Res.string.player_track_details_album_release_date), track.album?.releaseDate) + DetailRow(stringResource(Res.string.player_track_details_album_track_count), track.album?.trackCount?.toString()) + DetailRow(stringResource(Res.string.player_track_details_album_genres), track.album?.genres?.joinToString()) + DetailRow(stringResource(Res.string.player_track_details_duration), formatRemainingTime(track.durationMs)) + DetailRow(stringResource(Res.string.player_track_details_track_number), track.trackNumber?.toString()) + DetailRow(stringResource(Res.string.player_track_details_disc_number), track.discNumber?.toString()) + DetailRow(stringResource(Res.string.player_track_details_explicit), track.explicit?.toString()) + DetailRow(stringResource(Res.string.player_track_details_popularity), track.popularity?.toString()) + DetailRow(stringResource(Res.string.player_track_details_isrc), track.isrcCode) + DetailRow(stringResource(Res.string.player_track_details_external_uri), track.externalUri) + DetailRow(stringResource(Res.string.player_track_details_thumbnail_urls), track.thumbnails?.joinToString { it.url }) } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt index c811248b..012caee7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -45,6 +45,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_track_details_load_failed +import spotube.composeapp.generated.resources.player_track_details_no_cached_source +import spotube.composeapp.generated.resources.player_track_details_streams_not_cached data class PlayerTrackDetails( val track: MetadataTrack, @@ -58,7 +63,10 @@ data class PlayerTrackDetails( val activeStreamMatchesPreference: Boolean? = null, val localCacheEntry: CacheEntry? = null, val isLoading: Boolean = false, - val error: String? = null) + /** Raw error message (e.g. from an exception); takes precedence over [errorRes]. */ + val error: String? = null, + /** Localized error to show when [error] is null. */ + val errorRes: StringResource? = null) data class PlayerOptionsUiState( val isMoreOptionsMenuOpen: Boolean = false, @@ -187,11 +195,11 @@ class PlayerOptionsViewModel( activeStreamUrl = activeStreamUrl, activeStreamMatchesPreference = activeStreamMatchesPreference, localCacheEntry = localCacheEntry, - error = when { + errorRes = when { source == null && localCacheEntry == null -> - "No matched audio source is cached for this track." + Res.string.player_track_details_no_cached_source streams.isEmpty() && localCacheEntry == null -> - "Stream details are not cached yet. Play the track first to load them." + Res.string.player_track_details_streams_not_cached else -> null }) } @@ -209,7 +217,8 @@ class PlayerOptionsViewModel( trackDetails = PlayerTrackDetails( track = track, isLoading = false, - error = error.message ?: "Failed to load track details.")) + error = error.message, + errorRes = Res.string.player_track_details_load_failed)) } else current } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt index 662ea8ed..129b6ea1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -55,6 +55,14 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import org.koin.compose.viewmodel.koinViewModel +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.alternative_track_active_source +import spotube.composeapp.generated.resources.alternative_track_confidence +import spotube.composeapp.generated.resources.alternative_track_empty +import spotube.composeapp.generated.resources.alternative_track_no_track +import spotube.composeapp.generated.resources.alternative_track_searching +import spotube.composeapp.generated.resources.alternative_track_title @Composable fun AlternativeTrackContent( @@ -73,13 +81,13 @@ fun AlternativeTrackContent( .padding(horizontal = 8.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - text = "Alternative Sources", + text = stringResource(Res.string.alternative_track_title), style = AppTheme.typography.titleLarge) val currentTrack = uiState.currentTrack if (currentTrack == null) { Text( - text = "No track currently playing", + text = stringResource(Res.string.alternative_track_no_track), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) return@Column @@ -94,7 +102,7 @@ fun AlternativeTrackContent( modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(12.dp)) Text( - text = "Searching for alternative sources...", + text = stringResource(Res.string.alternative_track_searching), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) } @@ -103,7 +111,7 @@ fun AlternativeTrackContent( if (uiState.alternatives.isEmpty()) { Text( - text = "No alternative sources found", + text = stringResource(Res.string.alternative_track_empty), style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) return@Column @@ -186,7 +194,7 @@ private fun AlternativeSourceRow( overflow = TextOverflow.Ellipsis) } Text( - text = "Confidence: ${(source.confidence * 100).toInt()}%", + text = stringResource(Res.string.alternative_track_confidence, (source.confidence * 100).toInt()), style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant) } @@ -195,7 +203,7 @@ private fun AlternativeSourceRow( if (isActive) { MillenniumIcon( Iconsax.IconsaxCheckCircle, - contentDescription = "Active source", + contentDescription = stringResource(Res.string.alternative_track_active_source), tint = AppTheme.colors.primary, modifier = Modifier.size(22.dp)) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index f845a2d1..67fbf043 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -81,6 +81,17 @@ import spotube.composeapp.generated.resources.queue_clear_queue import spotube.composeapp.generated.resources.queue_filter_queue import spotube.composeapp.generated.resources.queue_no_queue_entries import spotube.composeapp.generated.resources.queue_title +import spotube.composeapp.generated.resources.jam_participants_ban +import spotube.composeapp.generated.resources.jam_participants_host +import spotube.composeapp.generated.resources.player_more_options_content_description +import spotube.composeapp.generated.resources.queue_item_subtitle_added_by +import spotube.composeapp.generated.resources.queue_participant_ban_description +import spotube.composeapp.generated.resources.queue_participant_kick +import spotube.composeapp.generated.resources.queue_participant_kick_description +import spotube.composeapp.generated.resources.queue_participant_remove_suggestions +import spotube.composeapp.generated.resources.queue_participant_remove_suggestions_description +import spotube.composeapp.generated.resources.queue_remove_from_queue +import spotube.composeapp.generated.resources.queue_reorder_content_description @Composable fun PlayerQueueContent( @@ -214,7 +225,7 @@ private fun ParticipantDialog( ) if (participant.isHost) { Text( - text = "Host", + text = stringResource(Res.string.jam_participants_host), style = AppTheme.typography.labelSmall, color = AppTheme.colors.primary ) @@ -237,8 +248,8 @@ private fun ParticipantDialog( tint = AppTheme.colors.error ) }, - title = { Text("Kick") }, - subtitle = { Text("Remove them from the session") }) + title = { Text(stringResource(Res.string.queue_participant_kick)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_kick_description)) }) MillenniumListTile( onClick = { onBan() @@ -251,8 +262,8 @@ private fun ParticipantDialog( tint = AppTheme.colors.error ) }, - title = { Text("Ban") }, - subtitle = { Text("Kick and prevent them from rejoining") }) + title = { Text(stringResource(Res.string.jam_participants_ban)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_ban_description)) }) MillenniumListTile( onClick = { onRemoveSuggestions() @@ -264,8 +275,8 @@ private fun ParticipantDialog( contentDescription = null ) }, - title = { Text("Remove suggestions") }, - subtitle = { Text("Remove every track they added to the queue") }) + title = { Text(stringResource(Res.string.queue_participant_remove_suggestions)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_remove_suggestions_description)) }) } } } @@ -315,7 +326,7 @@ private fun QueueItemRow( ) { MillenniumIcon( Iconsax.IconsaxDragHandle, - contentDescription = if (reorderScope != null) "Reorder" else null, + contentDescription = if (reorderScope != null) stringResource(Res.string.queue_reorder_content_description) else null, modifier = Modifier .size(24.dp) .then( @@ -370,7 +381,7 @@ private fun QueueItemRow( }, subtitle = { Text( - text = item.subtitle, + text = item.addedByParticipant?.let { stringResource(Res.string.queue_item_subtitle_added_by, item.subtitle, it.displayName) } ?: item.subtitle, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, @@ -408,7 +419,7 @@ private fun QueueItemRow( MillenniumDropdownMenu( expanded = showMenu, onExpandedChange = { showMenu = it }, - items = listOf("Remove from queue"), + items = listOf(stringResource(Res.string.queue_remove_from_queue)), onItemClick = { onRemoveClick() showMenu = false @@ -420,7 +431,7 @@ private fun QueueItemRow( ) { MillenniumIcon( Iconsax.Iconsax3DotsMore, - contentDescription = "More options" + contentDescription = stringResource(Res.string.player_more_options_content_description) ) } }) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 253c8d83..f13e6b78 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -75,7 +75,7 @@ class PlayerQueueContentViewModel( } queue.mapIndexed { index, entry -> val title: String - var subtitle: String + val subtitle: String val durationMs: Long val imageUrl: String? @@ -97,9 +97,6 @@ class PlayerQueueContentViewModel( } val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy } - if (addedByParticipant != null) { - subtitle = "$subtitle • Added by ${addedByParticipant.displayName}" - } QueueItemUi( id = "${entry.url}@$index", @@ -127,7 +124,8 @@ class PlayerQueueContentViewModel( val filtered = if (isFiltered) { items.filter { item -> item.title.lowercase().contains(normalizedFilter) || - item.subtitle.lowercase().contains(normalizedFilter) + item.subtitle.lowercase().contains(normalizedFilter) || + item.addedByParticipant?.displayName?.lowercase()?.contains(normalizedFilter) == true } } else { items diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.ios.kt new file mode 100644 index 00000000..aaf1c7d2 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.ios.kt @@ -0,0 +1,41 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.staticCompositionLocalOf +import platform.Foundation.NSUserDefaults + +// Compose (and Compose resources) read NSLocale.preferredLanguages, which honours the +// app-level AppleLanguages default. +private const val APPLE_LANGUAGES_KEY = "AppleLanguages" +private const val SYSTEM_LANGUAGE = "system" + +private val LocalAppLocale = staticCompositionLocalOf { SYSTEM_LANGUAGE } + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + val defaults = NSUserDefaults.standardUserDefaults + if (languageTag == null) { + defaults.removeObjectForKey(APPLE_LANGUAGES_KEY) + } else { + defaults.setObject(listOf(languageTag), APPLE_LANGUAGES_KEY) + } + return LocalAppLocale provides (languageTag ?: SYSTEM_LANGUAGE) +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.jvm.kt new file mode 100644 index 00000000..d4d728ef --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.jvm.kt @@ -0,0 +1,36 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.staticCompositionLocalOf +import java.util.Locale + +// Captured before the first override so "System default" can restore it. +private val systemLocale: Locale = Locale.getDefault() + +private val LocalAppLocale = staticCompositionLocalOf { systemLocale.toLanguageTag() } + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + val locale = languageTag?.let(Locale::forLanguageTag) ?: systemLocale + // Compose resources read Locale.getDefault() both in composition and in getString(). + Locale.setDefault(locale) + return LocalAppLocale provides locale.toLanguageTag() +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt index 2dd89abe..bbc55527 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt @@ -25,7 +25,28 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.composenativetray.tray.api.Tray +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.tray_exit +import spotube.composeapp.generated.resources.tray_hide_window +import spotube.composeapp.generated.resources.tray_like_track +import spotube.composeapp.generated.resources.tray_loop +import spotube.composeapp.generated.resources.tray_loop_all +import spotube.composeapp.generated.resources.tray_loop_off +import spotube.composeapp.generated.resources.tray_loop_one +import spotube.composeapp.generated.resources.tray_mute +import spotube.composeapp.generated.resources.tray_next_track +import spotube.composeapp.generated.resources.tray_previous_track +import spotube.composeapp.generated.resources.tray_show_window +import spotube.composeapp.generated.resources.tray_shuffle +import spotube.composeapp.generated.resources.tray_unlike_track +import spotube.composeapp.generated.resources.tray_unmute +import spotube.composeapp.generated.resources.tray_volume +import spotube.composeapp.generated.resources.tray_volume_down +import spotube.composeapp.generated.resources.tray_volume_up /** * Renders the system tray icon + menu via Compose Native Tray (no AWT/Swing). @@ -42,46 +63,67 @@ fun NucleusApplicationScope.SystemTray( val trayService = koinInject() val state by trayService.state.collectAsState() + // Tray menu builders aren't composable, so resolve every label up front. + val hideWindowLabel = stringResource(Res.string.tray_hide_window) + val showWindowLabel = stringResource(Res.string.tray_show_window) + val playLabel = stringResource(Res.string.player_play_content_description) + val pauseLabel = stringResource(Res.string.player_pause_content_description) + val nextTrackLabel = stringResource(Res.string.tray_next_track) + val previousTrackLabel = stringResource(Res.string.tray_previous_track) + val shuffleLabel = stringResource(Res.string.tray_shuffle) + val loopLabel = stringResource(Res.string.tray_loop) + val loopOffLabel = stringResource(Res.string.tray_loop_off) + val loopOneLabel = stringResource(Res.string.tray_loop_one) + val loopAllLabel = stringResource(Res.string.tray_loop_all) + val volumeLabel = stringResource(Res.string.tray_volume, (state.volume * 100).toInt()) + val volumeUpLabel = stringResource(Res.string.tray_volume_up) + val volumeDownLabel = stringResource(Res.string.tray_volume_down) + val muteLabel = stringResource(Res.string.tray_mute) + val unmuteLabel = stringResource(Res.string.tray_unmute) + val likeTrackLabel = stringResource(Res.string.tray_like_track) + val unlikeTrackLabel = stringResource(Res.string.tray_unlike_track) + val exitLabel = stringResource(Res.string.tray_exit) + Tray( icon = Iconsax.IconsaxMusic, tooltip = "Spotube", primaryAction = onToggleWindowVisibility) { - Item(label = if (isWindowVisible) "Hide Window" else "Show Window") { + Item(label = if (isWindowVisible) hideWindowLabel else showWindowLabel) { onToggleWindowVisibility() } Divider() - Item(label = if (state.isPlaying) "Pause" else "Play") { + Item(label = if (state.isPlaying) pauseLabel else playLabel) { trayService.togglePlayPause() } - Item(label = "Next Track") { trayService.skipToNext() } - Item(label = "Previous Track") { trayService.skipToPrevious() } + Item(label = nextTrackLabel) { trayService.skipToNext() } + Item(label = previousTrackLabel) { trayService.skipToPrevious() } Divider() CheckableItem( - label = "Shuffle", + label = shuffleLabel, checked = state.shuffleEnabled, onCheckedChange = trayService::setShuffle) - SubMenu(label = "Loop") { - Item(label = "Loop: OFF") { trayService.setLoop(LoopState.NONE) } - Item(label = "Loop: ONE") { trayService.setLoop(LoopState.ONE) } - Item(label = "Loop: ALL") { trayService.setLoop(LoopState.ALL) } + SubMenu(label = loopLabel) { + Item(label = loopOffLabel) { trayService.setLoop(LoopState.NONE) } + Item(label = loopOneLabel) { trayService.setLoop(LoopState.ONE) } + Item(label = loopAllLabel) { trayService.setLoop(LoopState.ALL) } } Divider() - Item(label = "Volume: ${(state.volume * 100).toInt()}%") - Item(label = "Volume +") { trayService.increaseVolume() } - Item(label = "Volume -") { trayService.decreaseVolume() } - Item(label = if (state.volume <= 0f) "Unmute" else "Mute") { trayService.toggleMute() } + Item(label = volumeLabel) + Item(label = volumeUpLabel) { trayService.increaseVolume() } + Item(label = volumeDownLabel) { trayService.decreaseVolume() } + Item(label = if (state.volume <= 0f) unmuteLabel else muteLabel) { trayService.toggleMute() } Divider() if (state.currentTrackId != null) { - Item(label = if (state.isCurrentTrackLiked) "Unlike Track" else "Like Track") { + Item(label = if (state.isCurrentTrackLiked) unlikeTrackLabel else likeTrackLabel) { trayService.toggleLike() } } Divider() - Item(label = "Exit") { onExit() } + Item(label = exitLabel) { onExit() } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index 34f67902..a6ed3e85 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -54,7 +54,13 @@ import dev.nucleusframework.window.WindowControls import dev.nucleusframework.window.WindowControlsRenderer import dev.nucleusframework.window.WindowDoubleClickAction import dev.nucleusframework.window.windowDragArea +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.app_window_close +import spotube.composeapp.generated.resources.app_window_maximize +import spotube.composeapp.generated.resources.app_window_minimize +import spotube.composeapp.generated.resources.app_window_restore private val WindowControlsReservedWidth = 132.dp @@ -109,7 +115,7 @@ private fun WindowButtons(modifier: Modifier = Modifier) { MillenniumIconButton( onClick = { windowState.isMinimized = true }, style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentMinus, stringResource(Res.string.app_window_minimize), modifier = Modifier.size(14.dp)) } MillenniumIconButton( onClick = { @@ -118,9 +124,9 @@ private fun WindowButtons(modifier: Modifier = Modifier) { }, style = MillenniumIconButtonStyle.Quiet) { if (windowState.placement == WindowPlacement.Floating) { - MillenniumIcon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentMaximize, stringResource(Res.string.app_window_maximize), modifier = Modifier.size(14.dp)) } else { - MillenniumIcon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentSquareMultiple, stringResource(Res.string.app_window_restore), modifier = Modifier.size(14.dp)) } } MillenniumIconButton( @@ -132,7 +138,7 @@ private fun WindowButtons(modifier: Modifier = Modifier) { } }, style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentDismiss, stringResource(Res.string.app_window_close), modifier = Modifier.size(14.dp)) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 9cc41de2..df97c006 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -37,7 +37,9 @@ import dev.krtirtho.spotube.core.systemtray.SystemTrayService import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope import dev.krtirtho.spotube.core.ui.component.LocalWindowScope import dev.krtirtho.spotube.core.ui.component.LocalWindowState +import dev.krtirtho.spotube.core.i18n.AppLocaleProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider +import dev.krtirtho.spotube.modules.settings.SupportedLanguages import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication @@ -89,10 +91,13 @@ fun main() { trayService.setCallbacks(onToggleWindowVisibility) if (minimizeToTray) { - SystemTray( - isWindowVisible = isWindowVisible, - onToggleWindowVisibility = onToggleWindowVisibility, - onExit = onExit) + // The tray lives outside App(), so it needs its own provider to follow the app language. + AppLocaleProvider(settings?.language ?: SupportedLanguages.SYSTEM) { + SystemTray( + isWindowVisible = isWindowVisible, + onToggleWindowVisibility = onToggleWindowVisibility, + onExit = onExit) + } } DecoratedWindow( diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/i18n/AppLocaleProviderTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/i18n/AppLocaleProviderTest.kt new file mode 100644 index 00000000..91dac67b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/i18n/AppLocaleProviderTest.kt @@ -0,0 +1,91 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.i18n + +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.use +import dev.krtirtho.spotube.modules.settings.SupportedLanguages +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.plugin_installed_plugins_count +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalComposeUiApi::class) +class AppLocaleProviderTest { + @Test + fun switchingLanguageUpdatesStringsWithoutRestart() { + val systemLocale = Locale.getDefault() + var language by mutableStateOf(SupportedLanguages.DE) + var cancel = "" + var plugins = "" + var direction = LayoutDirection.Ltr + + try { + ImageComposeScene(width = 100, height = 100) { + AppLocaleProvider(language) { + val cancelText = stringResource(Res.string.common_cancel) + val pluginsText = pluralStringResource(Res.plurals.plugin_installed_plugins_count, 3, 3) + val layoutDirection = LocalLayoutDirection.current + SideEffect { + cancel = cancelText + plugins = pluginsText + direction = layoutDirection + } + } + }.use { scene -> + scene.render() + assertEquals("Abbrechen", cancel) + assertEquals("3 Plugins installiert", plugins) + // Non-composable lookups (ViewModels, services) follow the override too. + assertEquals("Abbrechen", runBlocking { getString(Res.string.common_cancel) }) + + language = SupportedLanguages.ZH_TW + scene.render() + assertEquals("取消", cancel) + assertEquals("已安裝 3 個外掛程式", plugins) + + language = SupportedLanguages.RU + scene.render() + assertEquals("3 плагина установлено", plugins) + + language = SupportedLanguages.AR + scene.render() + assertEquals(LayoutDirection.Rtl, direction) + + language = SupportedLanguages.SYSTEM + scene.render() + assertEquals(systemLocale, Locale.getDefault()) + } + } finally { + Locale.setDefault(systemLocale) + } + } +} From 2bef59ee765d0a810b425e6cdebd0089b694cd96 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Oct 2026 23:26:42 +0600 Subject: [PATCH 052/132] chore: add l10n strings --- .../settings/sections/LanguageRegionSection.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt index 7d1a1eb1..37adca6b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -19,10 +19,6 @@ package dev.krtirtho.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable -import compose.icons.FeatherIcons -import compose.icons.feathericons.Globe -import compose.icons.feathericons.MapPin -import spotube.composeapp.generated.resources.* import dev.krtirtho.spotube.modules.settings.CountryCode import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SupportedLanguages @@ -32,6 +28,14 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_country_subtitle_current +import spotube.composeapp.generated.resources.settings_country_title +import spotube.composeapp.generated.resources.settings_language_subtitle_current +import spotube.composeapp.generated.resources.settings_language_system_default +import spotube.composeapp.generated.resources.settings_language_title +import spotube.composeapp.generated.resources.settings_option_name_and_code +import spotube.composeapp.generated.resources.settings_section_language_region internal fun LazyListScope.languageRegionSection( settings: UserSettings, From daf54b74f41c03c93ac1ba8845f843fd3d427cb1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 09:29:08 +0600 Subject: [PATCH 053/132] feat(plugin): implement MetadataPluginGuard to manage plugin dependencies and user prompts --- composeApp/build.gradle.kts | 8 + .../composeResources/values/strings.xml | 12 + .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../core/navigation/NavigationModule.kt | 86 +++--- .../spotube/modules/library/LibraryScreen.kt | 21 +- .../local_tracks/LibraryLocalTracksScreen.kt | 67 +++-- .../plugin/guard/MetadataPluginGuard.kt | 244 ++++++++++++++++++ .../guard/MetadataPluginGuardViewModel.kt | 75 ++++++ .../components/LocalMediaFoldersEditor.kt | 27 ++ .../settings/sections/DownloadsSection.kt | 33 +-- 10 files changed, 486 insertions(+), 89 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 750ed2c6..1e1af578 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -336,6 +336,14 @@ android { } +// ditchoom's socket-quic-quiche ships libquiche_jni.so with 4 KB LOAD segment alignment +// (not 16 KB compatible), even in the latest release. It is only an optional QUIC transport +// pulled in transitively by mqtt-client, so drop it on Android until upstream fixes it. +configurations.matching { it.name.startsWith("debug") || it.name.startsWith("release") }.configureEach { + exclude(group = "com.ditchoom", module = "socket-quic-quiche") + exclude(group = "com.ditchoom", module = "socket-quic-quiche-android") +} + dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs) debugImplementation(libs.compose.uiTooling) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 62a749a7..296ddb3f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -68,6 +68,8 @@ Allow audio access to browse music on your device. Grant access No local folders found + Add a folder with music to play it right here. No plugin needed. + Add folder No tracks in this folder No matching folders or tracks Failed to refresh local media @@ -263,6 +265,16 @@ Failed to download plugin No plugins installed yet Paste a download URL or pick a file above. + No metadata plugin selected + Spotube needs a metadata plugin to browse, search and show your music. Install one to get started. + You already have plugins installed, but none is set as your metadata source. Pick one to start browsing. + Discover a metadata plugin + Review its permissions and install it + Select it as your metadata source + Install a plugin + Choose a plugin + Play local music + Your local tracks and downloads work without a plugin. %1$d plugin installed %1$d plugins installed diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 769c08ff..e5bcc686 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -70,6 +70,7 @@ import dev.krtirtho.spotube.modules.plugin.AudioPluginSource import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginViewModel +import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuardViewModel import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel @@ -138,6 +139,7 @@ val sharedModules = module { bind() } viewModelOf(::PluginViewModel) + viewModelOf(::MetadataPluginGuardViewModel) // Settings singleOf(::SettingsRepository) { bind() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index 4fc959dc..88bdeaef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -29,6 +29,7 @@ import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.playlist.PlaylistScreen import dev.krtirtho.spotube.modules.plugin.PluginScreen +import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.search.SearchScreen @@ -101,12 +102,17 @@ sealed interface Routes : NavKey { val navigationModule = module { singleOf(::NavigationCommands) navigation { - HomeScreen(viewModel = koinViewModel()) + MetadataPluginGuard { + HomeScreen(viewModel = koinViewModel()) + } } navigation { - SearchScreen() + MetadataPluginGuard { + SearchScreen() + } } navigation { + // Not guarded as a whole: Local tracks and Downloads work without a plugin. LibraryScreen() } navigation { @@ -117,49 +123,57 @@ val navigationModule = module { } navigation {} navigation { - PlaylistScreen( - playlistId = it.playlistId, - viewModel = koinViewModel( - key = it.playlistId, - parameters = { parametersOf(it.playlistId) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get()) + MetadataPluginGuard(backButton = true) { + PlaylistScreen( + playlistId = it.playlistId, + viewModel = koinViewModel( + key = it.playlistId, + parameters = { parametersOf(it.playlistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } } navigation { - ArtistScreen( - viewModel = koinViewModel( - key = it.artistId, - parameters = { parametersOf(it.artistId) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get()) + MetadataPluginGuard(backButton = true) { + ArtistScreen( + viewModel = koinViewModel( + key = it.artistId, + parameters = { parametersOf(it.artistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } } navigation { - AlbumScreen( - albumId = it.albumId, - viewModel = koinViewModel( - key = it.albumId, - parameters = { parametersOf(it.albumId) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get()) + MetadataPluginGuard(backButton = true) { + AlbumScreen( + albumId = it.albumId, + viewModel = koinViewModel( + key = it.albumId, + parameters = { parametersOf(it.albumId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } } navigation { LyricsScreen(viewModel = koinViewModel()) } navigation { - SavedTracksScreen( - viewModel = koinViewModel( - key = SAVED_TRACKS_COLLECTION_ID, - parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get()) + MetadataPluginGuard(backButton = true) { + SavedTracksScreen( + viewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } } navigation { BlacklistScreen() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index 4b7b24af..ae863f1f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -57,6 +57,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import org.jetbrains.compose.resources.StringResource +import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.spotube.millennium.adaptive.WindowBreakpoints @@ -116,14 +117,20 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { } Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { when (state.currentTab) { - LibraryTab.Playlists -> LibraryPlaylistsScreen( - showCreatePlaylist = state.showCreatePlaylist, - onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, - viewMode = viewMode - ) + LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false) { + LibraryPlaylistsScreen( + showCreatePlaylist = state.showCreatePlaylist, + onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, + viewMode = viewMode + ) + } - LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode) - LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode) + LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false) { + LibraryAlbumsScreen(viewMode = viewMode) + } + LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false) { + LibraryArtistsScreen(viewMode = viewMode) + } LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.Downloads -> DownloadsScreen() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 1cd80768..c2f3b405 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -36,6 +36,9 @@ import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel +import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost +import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen @@ -58,7 +61,8 @@ import spotube.composeapp.generated.resources.* @Composable fun LibraryLocalTracksScreen( modifier: Modifier = Modifier, - viewModel: LibraryLocalTracksViewModel = koinViewModel() + viewModel: LibraryLocalTracksViewModel = koinViewModel(), + foldersViewModel: LocalMediaFoldersViewModel = koinViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() val permission = rememberLocalMediaPermissionState() @@ -66,8 +70,10 @@ fun LibraryLocalTracksScreen( LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } LocalTracksContent( state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, - viewModel::playCurrentFolder, permission.requestPermission, modifier + viewModel::playCurrentFolder, permission.requestPermission, modifier, + onManageFolders = foldersViewModel::open ) + LocalMediaFoldersDialogHost(foldersViewModel) } @Composable @@ -78,14 +84,15 @@ internal fun LocalTracksContent( onNavigateUp: () -> Unit, onPlayFolder: () -> Unit, onRequestPermission: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onManageFolders: () -> Unit = {} ) { val inset = LocalAppShellBottomInset.current Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column( Modifier.widthIn(max = 960.dp).fillMaxSize() ) { - LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder) + LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder, onManageFolders = onManageFolders) when { !state.canAccessMedia -> MediaEmptyState( Iconsax.IconsaxFolderOpen, @@ -143,16 +150,35 @@ internal fun LocalTracksContent( } } - else -> MediaEmptyState( - Iconsax.IconsaxFolderOpen, - state.error ?: stringResource( - when { - state.query.isNotBlank() -> Res.string.library_local_no_results - state.currentFolderPath == null -> Res.string.library_local_empty_folders - else -> Res.string.library_local_empty_tracks - } - ), Modifier.padding(bottom = inset) - ) + else -> { + val noFolders = state.currentFolderPath == null && state.query.isBlank() + MediaEmptyState( + Iconsax.IconsaxFolderOpen, + state.error ?: stringResource( + when { + state.query.isNotBlank() -> Res.string.library_local_no_results + state.currentFolderPath == null -> Res.string.library_local_empty_folders + else -> Res.string.library_local_empty_tracks + } + ), Modifier.padding(bottom = inset), + description = if (noFolders && state.error == null) + stringResource(Res.string.library_local_empty_folders_description) else null, + action = if (noFolders) ({ + MillenniumButton(onClick = onManageFolders) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderAdd, null, + modifier = Modifier.size(18.dp) + ) + Text(stringResource(Res.string.library_local_add_folder)) + } + } + }) else null + ) + } } } } @@ -163,7 +189,8 @@ internal fun LocalTracksBreadcrumb( state: LibraryLocalTracksState, onNavigateUp: () -> Unit, onPlayFolder: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onManageFolders: (() -> Unit)? = null ) { val backDescription = stringResource(Res.string.library_local_back) Row( @@ -238,6 +265,16 @@ internal fun LocalTracksBreadcrumb( maxLines = 1 ) if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp)) + if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton( + onClick = onManageFolders, + modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxFolderAdd, + stringResource(Res.string.library_local_add_folder), + modifier = Modifier.size(14.dp) + ) + } if (state.currentFolderPath != null) MillenniumIconButton( onClick = onPlayFolder, enabled = state.canPlayFolder, modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt new file mode 100644 index 00000000..d5ffc44d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -0,0 +1,244 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.plugin.guard + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd +import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.no_metadata_plugin_action_install +import spotube.composeapp.generated.resources.no_metadata_plugin_action_local +import spotube.composeapp.generated.resources.no_metadata_plugin_local_hint +import spotube.composeapp.generated.resources.no_metadata_plugin_action_select +import spotube.composeapp.generated.resources.no_metadata_plugin_description_install +import spotube.composeapp.generated.resources.no_metadata_plugin_description_select +import spotube.composeapp.generated.resources.no_metadata_plugin_step_discover +import spotube.composeapp.generated.resources.no_metadata_plugin_step_install +import spotube.composeapp.generated.resources.no_metadata_plugin_step_select +import spotube.composeapp.generated.resources.no_metadata_plugin_title + +/** + * Renders [content] only while a metadata plugin is selected. Otherwise shows a guided + * "no plugin" state with a call to action leading to the plugin manager, instead of letting + * the screen sit on an endless loading skeleton. + * + * @param backButton whether the placeholder's top bar shows a back button (for pushed screens). + * @param showTopBar false when the host screen already draws its own app bar (e.g. Library tabs). + */ +@Composable +fun MetadataPluginGuard( + backButton: Boolean = false, + showTopBar: Boolean = true, + viewModel: MetadataPluginGuardViewModel = koinViewModel(), + content: @Composable () -> Unit +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + when (val s = state) { + is MetadataPluginGuardState.Available -> content() + is MetadataPluginGuardState.Loading -> Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) + is MetadataPluginGuardState.Missing -> NoMetadataPluginScreen( + hasInstalledPlugins = s.hasInstalledPlugins, + backButton = backButton, + showTopBar = showTopBar, + onOpenPlugins = viewModel::openPlugins, + onOpenLocalTracks = viewModel::openLocalTracks) + } +} + +@Composable +private fun NoMetadataPluginScreen( + hasInstalledPlugins: Boolean, + backButton: Boolean, + showTopBar: Boolean, + onOpenPlugins: () -> Unit, + onOpenLocalTracks: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + if (showTopBar) ApplicationMainBar(backButton = backButton) + + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = LocalAppShellBottomInset.current), + contentAlignment = Alignment.Center) { + Column( + modifier = Modifier + .widthIn(max = 420.dp) + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + HeroBadge() + + Text( + text = stringResource(Res.string.no_metadata_plugin_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = stringResource( + if (hasInstalledPlugins) Res.string.no_metadata_plugin_description_select + else Res.string.no_metadata_plugin_description_install), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + + if (!hasInstalledPlugins) { + MillenniumCard(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Step(1, Res.string.no_metadata_plugin_step_discover) + Step(2, Res.string.no_metadata_plugin_step_install) + Step(3, Res.string.no_metadata_plugin_step_select) + } + } + } + + MillenniumButton( + onClick = onOpenPlugins, + style = MillenniumButtonStyle.Primary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + if (hasInstalledPlugins) Iconsax.IconsaxCheckCircle else Iconsax.IconsaxAdd, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text( + stringResource( + if (hasInstalledPlugins) Res.string.no_metadata_plugin_action_select + else Res.string.no_metadata_plugin_action_install)) + } + } + + MillenniumButton( + onClick = onOpenLocalTracks, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + Iconsax.IconsaxMusicLibrary, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text(stringResource(Res.string.no_metadata_plugin_action_local)) + } + } + + Text( + text = stringResource(Res.string.no_metadata_plugin_local_hint), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun HeroBadge() { + val primary = AppTheme.colors.primary + Box( + modifier = Modifier + .size(104.dp) + .clip(CircleShape) + .background(Brush.radialGradient(listOf(primary.copy(alpha = 0.22f), primary.copy(alpha = 0f)))), + contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBoxAdd, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = primary) + } + } +} + +@Composable +private fun Step(index: Int, label: StringResource) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + Text( + text = index.toString(), + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.primary) + } + Text( + text = stringResource(label), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt new file mode 100644 index 00000000..f9346ef2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt @@ -0,0 +1,75 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.plugin.guard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.modules.library.LibraryState +import dev.krtirtho.spotube.modules.library.LibraryTab +import dev.krtirtho.spotube.modules.plugin.PluginAbility +import dev.krtirtho.spotube.modules.plugin.PluginManager +import dev.krtirtho.spotube.modules.plugin.PluginManagerStates +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +sealed interface MetadataPluginGuardState { + data object Loading : MetadataPluginGuardState + data object Available : MetadataPluginGuardState + + /** No metadata plugin is selected. [hasInstalledPlugins] tells whether the user only needs + * to pick one (true) or has to install one first (false). */ + data class Missing(val hasInstalledPlugins: Boolean) : MetadataPluginGuardState +} + +class MetadataPluginGuardViewModel( + pluginManager: PluginManager, + private val navigationCommands: NavigationCommands, + private val libraryState: LibraryState +) : ViewModel() { + val uiState: StateFlow = pluginManager.state + .map { state -> + when (state) { + is PluginManagerStates.Loading -> MetadataPluginGuardState.Loading + is PluginManagerStates.Data -> + if (PluginAbility.METADATA in state.selectedPlugins) { + MetadataPluginGuardState.Available + } else { + MetadataPluginGuardState.Missing( + hasInstalledPlugins = state.plugins.any { PluginAbility.METADATA in it.abilities }) + } + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MetadataPluginGuardState.Loading) + + fun openPlugins() { + navigationCommands.navigateTo(Routes.Plugins) + } + + /** Local tracks are a plugin-free player, so offer them as the way forward. */ + fun openLocalTracks() { + libraryState.onTabSelected(LibraryTab.LocalTracks) + navigationCommands.navigateTo(Routes.Library) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt index ea4b2b5c..1e3ae674 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -18,6 +18,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import io.github.vinceglb.filekit.path +import org.spotube.millennium.composables.MillenniumDialog import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor @@ -255,3 +261,24 @@ private fun LocalMediaFolderEditorRow( } } } + +/** Renders the "manage local folders" dialog (folder picker included) while [viewModel] has it open. + * Shared by Settings and the Local Tracks screen; callers trigger it via [LocalMediaFoldersViewModel.open]. */ +@Composable +fun LocalMediaFoldersDialogHost(viewModel: LocalMediaFoldersViewModel) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val pickerLauncher = + rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) } + if (state.isOpen) { + MillenniumDialog( + visible = true, + onDismissRequest = viewModel::dismiss, + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent( + state, { pickerLauncher.launch() }, viewModel::removeFolder, + viewModel::save, viewModel::dismiss + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index a4656eea..6e2b5dd8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -20,7 +20,6 @@ package dev.krtirtho.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.lazy.LazyListScope @@ -31,8 +30,7 @@ import androidx.compose.runtime.getValue import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel -import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState -import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard @@ -185,8 +183,6 @@ private fun LocalMediaFoldersSettingCard( viewModel: LocalMediaFoldersViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val pickerLauncher = - rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) } val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) { stringResource(Res.string.settings_local_media_folders_subtitle_empty) } else stringResource( @@ -214,30 +210,5 @@ private fun LocalMediaFoldersSettingCard( onClick = viewModel::open ) - if (state.isOpen) { - LocalMediaFoldersDialog( - state = state, - onAddFolder = { pickerLauncher.launch() }, - onRemoveFolder = viewModel::removeFolder, - onSave = viewModel::save, - onDismiss = viewModel::dismiss - ) - } -} - -@Composable -private fun LocalMediaFoldersDialog( - state: LocalMediaFoldersUiState, - onAddFolder: () -> Unit, - onRemoveFolder: (String) -> Unit, - onSave: () -> Unit, - onDismiss: () -> Unit -) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = stringResource(Res.string.settings_local_media_folders_title) - ) { - LocalMediaFoldersEditorContent(state, onAddFolder, onRemoveFolder, onSave, onDismiss) - } + LocalMediaFoldersDialogHost(viewModel) } From c57e33fe0dfe6e714a4ebb01421c9064c122dbb0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 11:49:48 +0600 Subject: [PATCH 054/132] feat(ui): replace IndeterminateProgress with new loading components and enhance dialog structures --- .../composeResources/values/strings.xml | 5 + .../core/remote/ConnectionRequestDialog.kt | 35 +- .../ui/component/AdaptiveDialogBottomSheet.kt | 39 +- .../component/AdaptiveDropdownBottomSheet.kt | 221 +++++++---- .../spotube/core/ui/component/ErrorDisplay.kt | 62 ++- .../spotube/core/ui/component/TrackOptions.kt | 3 +- .../krtirtho/spotube/core/ui/misc/Shimmer.kt | 11 +- .../spotube/modules/artist/ArtistScreen.kt | 4 +- .../spotube/modules/devices/DevicesScreen.kt | 15 +- .../modules/devices/PlayDestinationPicker.kt | 18 +- .../modules/devices/RemoteControlScreen.kt | 18 +- .../modules/downloads/DownloadsScreen.kt | 9 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 23 +- .../modules/jam/PlayDestinationPicker.kt | 35 +- .../local_tracks/LibraryLocalTracksScreen.kt | 7 +- .../library/playlist/AddToPlaylistPicker.kt | 46 +-- .../library/playlist/PlaylistFormSheet.kt | 105 ++--- .../spotube/modules/lyrics/LyricsScreen.kt | 4 +- .../playlist/AddTracksToPlaylistDialog.kt | 51 +-- .../spotube/modules/plugin/PluginScreen.kt | 347 +++++++++-------- .../plugin/components/PluginDialogParts.kt | 249 ++++++++++++ .../plugin/components/PluginInstallDialog.kt | 254 ++++++------- .../components/PluginPermissionsDialog.kt | 359 +++++++----------- .../spotube/modules/search/SearchScreen.kt | 4 +- .../modules/settings/SettingsSidebarLayout.kt | 4 +- .../components/InteractiveSettingCards.kt | 52 +-- .../settings/sections/AppearanceSection.kt | 30 +- .../spotube/modules/shell/AppLargePlayer.kt | 51 +-- .../spotube/modules/shell/AppSidebar.kt | 7 +- .../modules/shell/PlayerOptionDialogs.kt | 61 +-- .../AlternativeTrackContent.kt | 5 +- .../shell/player_queue/PlayerQueueContent.kt | 17 +- .../spotube/modules/support/Support.kt | 85 +++-- .../spotube/modules/update/AppUpdateDialog.kt | 43 +-- .../modules/update/PluginUpdateDialog.kt | 50 +-- 35 files changed, 1304 insertions(+), 1025 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginDialogParts.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 296ddb3f..a98cb2e0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -279,6 +279,10 @@ %1$d plugin installed %1$d plugins installed + Add a plugin from a link or a file on your device. + Pick a .smplug file from your device + or + Plugin details Install a Plugin https://example.com/plugin.smplug Download @@ -572,6 +576,7 @@ Start typing to search Start typing to search tracks No results found + No results See all Searching Loading tracks... diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt index 68e1d03b..311169db 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.remote +import org.spotube.millennium.composables.MillenniumAlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -34,7 +35,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumDialog import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res @@ -56,21 +56,25 @@ fun ConnectionRequestDialogHost() { } pendingRequest?.let { request -> - MillenniumDialog( + MillenniumAlertDialog( visible = true, onDismissRequest = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) pendingRequest = null }, - title = stringResource(Res.string.remote_request_title)) { - Text( - text = stringResource(Res.string.remote_request_message, request.deviceName), - style = AppTheme.typography.bodyMedium, - textAlign = TextAlign.Start) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - verticalAlignment = Alignment.CenterVertically) { + title = { Text(stringResource(Res.string.remote_request_title)) }, + description = { + Text(stringResource(Res.string.remote_request_message, request.deviceName)) + }, + ) { + MillenniumButton( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) + pendingRequest = null + }, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.remote_request_allow_always)) + } MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) @@ -86,15 +90,6 @@ fun ConnectionRequestDialogHost() { }) { Text(stringResource(Res.string.remote_request_allow)) } - MillenniumButton( - onClick = { - handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) - pendingRequest = null - }, - style = MillenniumButtonStyle.Quiet) { - Text(stringResource(Res.string.remote_request_allow_always)) - } } - } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt index 24bda126..d35a8c38 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -19,6 +19,15 @@ package dev.krtirtho.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import dev.krtirtho.spotube.core.ui.theming.AppTheme import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle @@ -50,6 +59,9 @@ import spotube.composeapp.generated.resources.* fun AdaptiveDialogBottomSheet( onDismiss: () -> Unit, title: @Composable (() -> Unit)? = null, + // Plain-text title for the large-screen dialog's own titlebar; when set it replaces [title] + // there so the titlebar isn't left empty above a duplicated in-body title. + dialogTitle: String? = null, modifier: Modifier = Modifier, actions: @Composable (() -> Unit)? = null, content: @Composable () -> Unit) { @@ -59,14 +71,19 @@ fun AdaptiveDialogBottomSheet( MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = "", - modifier = modifier) { - title?.let { it() } - content() - actions?.let { - MillenniumSeparator() - it() + modifier = modifier, + paneTitle = dialogTitle ?: "") { + MillenniumDialogHeader { + if (dialogTitle != null) { + MillenniumDialogTitle(text = dialogTitle, textStyle = AppTheme.typography.titleMedium) + } else { + MillenniumDialogTitle { title?.invoke() } + } + MillenniumDialogClose( + contentDescription = stringResource(Res.string.common_close_dialog)) } + MillenniumDialogBody { content() } + actions?.let { MillenniumDialogFooter(content = { it() }) } } } else { val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) @@ -99,7 +116,13 @@ fun AdaptiveDialogBottomSheet( } Spacer(Modifier.height(12.dp)) } - content() + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState())) { + content() + } actions?.let { Spacer(Modifier.height(8.dp)) MillenniumSeparator() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index 5c7ec032..d66044b2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -23,6 +23,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -30,6 +40,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.draw.clip +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -41,6 +52,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -57,6 +69,14 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuContent +import org.spotube.millennium.composables.MillenniumDropdownMenuEmpty +import org.spotube.millennium.composables.MillenniumDropdownMenuFilter +import org.spotube.millennium.composables.MillenniumDropdownMenuHeader +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.millennium.composables.MillenniumDropdownMenuItemIcon +import org.spotube.millennium.composables.MillenniumDropdownMenuSeparator +import com.composeunstyled.Text import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle @@ -67,6 +87,7 @@ import org.spotube.millennium.composables.MillenniumText import org.spotube.millennium.composables.MillenniumTextField import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.accessibility_selected +import spotube.composeapp.generated.resources.common_no_results import spotube.composeapp.generated.resources.search_filter_placeholder data class AdaptiveMenuItem( @@ -99,17 +120,13 @@ fun AdaptiveDropdownBottomSheet( Box(modifier = modifier) { if (isLargeScreen) { - MillenniumDropdownMenu( + DesktopDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, - items = items.map { it.label }, - onItemClick = { label -> - items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke() - expanded = false - }, - minimumWidth = menuMinWidth, - anchor = { trigger { expanded = true } } - ) + items = items, + menuMinWidth = menuMinWidth, + filter = filter, + trigger = { trigger { expanded = true } }) } else { trigger { expanded = true } if (expanded) { @@ -134,6 +151,7 @@ private fun AdaptiveBottomSheetContent( header: @Composable (() -> Unit)?, filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { val hasSelection = items.any { it.selected } + val hasLeading = items.any { it.icon != null || it.leadingContent != null } val queryState = rememberTextFieldState() val query = queryState.text.toString() val filteredItems = if (filter != null && query.isNotBlank()) { @@ -175,69 +193,134 @@ private fun AdaptiveBottomSheetContent( if (item.dividerBefore) { MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) } - MillenniumListTile( - title = { - MillenniumText( - item.label, - style = AppTheme.typography.bodyLarge, - color = if (item.enabled) { - AppTheme.colors.onSurface - } else { - AppTheme.colors.onSurface.copy(alpha = 0.38f) - } - ) - }, - subtitle = item.subtitle?.let { subtitle -> - { - MillenniumText( - subtitle, - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant - ) - } - }, - leading = when { - item.leadingContent != null -> item.leadingContent - item.icon != null -> { - { - MillenniumIcon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier.size(22.dp) - ) - } - } - item.selected -> { - { - MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = stringResource(Res.string.accessibility_selected), - modifier = Modifier.size(22.dp) - ) - } - } - hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }} - else -> null - }, - trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) { - { - MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = stringResource(Res.string.accessibility_selected), - modifier = Modifier.size(22.dp) - ) - } - } else { - null - }, - selected = item.selected, - enabled = item.enabled, + AdaptiveMenuTile( + item = item, + hasSelection = hasSelection, + hasLeading = hasLeading, + textStyle = AppTheme.typography.bodyLarge, + minimumHeight = 56.dp, onClick = { item.onClick() onDismiss() - }, - minimumHeight = 56.dp - ) + }) + } + } + } + } +} + +/** One menu row. The leading slot is always reserved when any sibling has an icon/leading content + * (or a selection), so labels and icons stay aligned in a column. */ +@Composable +private fun AdaptiveMenuTile( + item: AdaptiveMenuItem, + hasSelection: Boolean, + hasLeading: Boolean, + textStyle: androidx.compose.ui.text.TextStyle, + minimumHeight: Dp, + onClick: () -> Unit) { + if (item.dividerBefore) { + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) + } + val contentColor = if (item.enabled) AppTheme.colors.onSurface + else AppTheme.colors.onSurface.copy(alpha = 0.38f) + MillenniumListTile( + title = { MillenniumText(item.label, style = textStyle, color = contentColor) }, + subtitle = item.subtitle?.let { subtitle -> + { + MillenniumText( + subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + }, + leading = when { + item.leadingContent != null -> item.leadingContent + item.icon != null -> { + { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = contentColor) + } + } + item.selected -> { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp)) + } + } + hasSelection || hasLeading -> {{ Spacer(modifier = Modifier.size(22.dp)) }} + else -> null + }, + trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp)) + } + } else { + null + }, + selected = item.selected, + enabled = item.enabled, + onClick = onClick, + minimumHeight = minimumHeight) +} + +/** Desktop popup built from Millennium's dropdown bricks: native icons, descriptions, + * selection indicator, separators and an optional search header. */ +@Composable +private fun DesktopDropdownMenu( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + items: List, + menuMinWidth: Dp, + filter: ((AdaptiveMenuItem, String) -> Boolean)?, + trigger: @Composable () -> Unit) { + val queryState = rememberTextFieldState() + LaunchedEffect(expanded) { if (!expanded) queryState.clearText() } + val query = queryState.text.toString() + val visibleItems = if (filter != null && query.isNotBlank()) { + items.filter { filter(it, query) } + } else { + items + } + val hasLeading = items.any { it.icon != null || it.leadingContent != null } + val iconSize = MillenniumTheme.dropdownMenuTheme.states.default.iconSize + + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = onExpandedChange, + minimumWidth = menuMinWidth, + width = menuMinWidth, + anchor = trigger) { + if (filter != null) { + MillenniumDropdownMenuHeader(showDivider = false) { + MillenniumDropdownMenuFilter( + state = queryState, + placeholder = stringResource(Res.string.search_filter_placeholder)) + } + } + MillenniumDropdownMenuContent { + if (visibleItems.isEmpty()) { + MillenniumDropdownMenuEmpty(stringResource(Res.string.common_no_results)) + } + visibleItems.forEach { item -> + if (item.dividerBefore) MillenniumDropdownMenuSeparator() + MillenniumDropdownMenuItem( + onClick = item.onClick, + enabled = item.enabled, + selected = item.selected, + leading = item.leadingContent + ?: item.icon?.let { icon -> { MillenniumDropdownMenuItemIcon(icon) } } + ?: if (hasLeading) ({ Spacer(Modifier.size(iconSize)) }) else null, + description = item.subtitle?.let { subtitle -> { Text(subtitle) } }) { + Text(item.label) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt index 398cd494..9a7dc563 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt @@ -18,6 +18,11 @@ package dev.krtirtho.spotube.core.ui.component +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -191,19 +196,11 @@ private fun ErrorLogDialog( onDismiss: () -> Unit) { val clipboardManager = LocalClipboardManager.current - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = "") { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically) { - Text( + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle( text = stringResource(Res.string.error_full_log_title), - style = AppTheme.typography.titleLarge, - fontWeight = FontWeight.Bold) - + textStyle = AppTheme.typography.titleMedium) MillenniumIconButton( onClick = { clipboardManager.setText(AnnotatedString(errorMessage)) @@ -213,31 +210,32 @@ private fun ErrorLogDialog( imageVector = FeatherIcons.Copy, contentDescription = stringResource(Res.string.error_copy_to_clipboard)) } + MillenniumDialogClose() } - Box( - modifier = Modifier - .fillMaxWidth() - .height(400.dp) - .clip(AppTheme.shapes.small) - .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) { - SelectionContainer { - Text( - text = errorMessage, - style = AppTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace), - color = AppTheme.colors.onSurfaceVariant, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(12.dp)) + MillenniumDialogBody(scrollable = false) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) { + SelectionContainer { + Text( + text = errorMessage, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp)) + } } } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End) { + MillenniumDialogFooter { MillenniumButton( onClick = onDismiss, - style = MillenniumButtonStyle.Quiet) { + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index e17d0d66..775d726a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -184,7 +185,7 @@ fun TrackOptionsBottomSheet( } else { AppTheme.colors.onSurface.copy(alpha = 0.38f) }) - } + } ?: Spacer(modifier = Modifier.size(22.dp)) Text( text = item.label, style = AppTheme.typography.bodyLarge, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt index 39795062..30954e17 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.unit.TextUnit import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush val LocalSkeletonLoading = compositionLocalOf { false } @@ -80,11 +82,16 @@ fun SkeletonTree( } fun Modifier.shimmerApply( - color: Color = Color.LightGray.copy(alpha = 0.5f) // Adjust alpha/color to match your theme + color: Color? = null // defaults to Millennium's recessed skeleton fill ): Modifier = composed { val isTreeLoading = LocalSkeletonLoading.current + val skeletonBrush = millenniumBevelBrush(MillenniumTheme.colors, sunken = true) - if (isTreeLoading) this.background(color) else this + when { + !isTreeLoading -> this + color != null -> this.background(color) + else -> this.background(skeletonBrush) + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 5092a82b..992cae40 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -84,7 +84,6 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -143,8 +142,7 @@ fun ArtistScreen( LazyColumn( state = listState, modifier = Modifier - .fillMaxSize() - .padding(bottom = LocalAppShellBottomInset.current + 24.dp), + .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(24.dp) ) { item { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index a6b029b7..e494ff27 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -18,10 +18,10 @@ package dev.krtirtho.spotube.modules.devices +import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -103,10 +103,9 @@ fun DevicesScreen( title = { Text(stringResource(Res.string.devices_title)) }, actions = { if (isDiscovering && connectingToDevice == null) { - MillenniumIndeterminateProgress( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp)) + MillenniumSpokeSpinner( + size = 24.dp, + modifier = Modifier.padding(end = 8.dp)) } else { MillenniumIcon( imageVector = Iconsax.IconsaxRefreshRight, @@ -177,8 +176,7 @@ fun DevicesScreen( Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - MillenniumIndeterminateProgress( - modifier = Modifier.size(24.dp)) + MillenniumSpokeSpinner(size = 24.dp) Text( text = stringResource(Res.string.devices_connecting), style = AppTheme.typography.bodyMedium) @@ -250,8 +248,7 @@ private fun DeviceRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (isConnecting) { - MillenniumIndeterminateProgress( - modifier = Modifier.size(24.dp)) + MillenniumSpokeSpinner(size = 24.dp) } else { MillenniumIcon( imageVector = Iconsax.IconsaxMirroringScreen, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 2aec64e1..95554726 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -18,6 +18,11 @@ package dev.krtirtho.spotube.modules.devices +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumListTile @@ -82,10 +87,12 @@ fun PlayDestinationPickerHost() { PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next) } - MillenniumDialog( - visible = true, - onDismissRequest = controller::dismissPicker, - title = stringResource(Res.string.devices_destination_title)) { + MillenniumDialog(visible = true, onDismissRequest = controller::dismissPicker) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.devices_destination_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -138,6 +145,9 @@ fun PlayDestinationPickerHost() { }) } + } + } + MillenniumDialogFooter { MillenniumButton( onClick = controller::dismissPicker, style = MillenniumButtonStyle.Quiet) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index 136c3408..fe3bc3be 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -18,6 +18,8 @@ package dev.krtirtho.spotube.modules.devices +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle @@ -604,11 +606,6 @@ private fun RemoteQueueItemRow( MillenniumDropdownMenu( expanded = showMenu, onExpandedChange = { showMenu = it }, - items = listOf(stringResource(Res.string.devices_remote_remove_from_queue)), - onItemClick = { - onRemoveClick() - showMenu = false - }, anchor = { MillenniumIconButton( onClick = { showMenu = true }, @@ -620,7 +617,16 @@ private fun RemoteQueueItemRow( modifier = Modifier.size(18.dp) ) } - }) + }) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.devices_remote_remove_from_queue), + icon = Iconsax.IconsaxMusicSquareRemove, + destructive = true, + onClick = { + onRemoveClick() + showMenu = false + }) + } } ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt index df59bd4b..0cfa15c7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt @@ -3,6 +3,7 @@ */ package dev.krtirtho.spotube.modules.downloads +import org.spotube.millennium.composables.MillenniumStripedProgress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,7 +43,6 @@ import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumProgress import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.millenniumPanelBrush import org.spotube.millennium.theme.millenniumSurface @@ -225,9 +225,10 @@ internal fun DownloadListRow( }, maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (item.status == DownloadStatus.Downloading) MillenniumProgress( - item.progress, - Modifier.fillMaxWidth().height(4.dp) + if (item.status == DownloadStatus.Downloading) MillenniumStripedProgress( + progress = item.progress, + modifier = Modifier.fillMaxWidth(), + height = 6.dp ) } MillenniumIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index 9180c0f8..7fa57b17 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,6 +17,9 @@ package dev.krtirtho.spotube.modules.jam +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove +import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -421,25 +424,27 @@ internal fun SessionPanel( ) } if (participant.canModerate) { - val removeLabel = stringResource(Res.string.jam_participants_remove) - val banLabel = stringResource(Res.string.jam_participants_ban) // Keep destructive moderation out of the participant's primary row. org.spotube.millennium.composables.MillenniumDropdownMenu( expanded = participant.menuExpanded, onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) }, - items = listOf(removeLabel, banLabel), - onItemClick = { label -> - if (label == removeLabel) onKick(participant.id) else onBan( - participant.id - ) - }, anchor = { MillenniumButton( onClick = { onMenuChange(participant.id) }, style = MillenniumButtonStyle.Secondary ) { Text(stringResource(Res.string.jam_participants_manage)) } } - ) + ) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.jam_participants_remove), + icon = Iconsax.IconsaxUserRemove, + onClick = { onKick(participant.id) }) + MillenniumDropdownMenuItem( + label = stringResource(Res.string.jam_participants_ban), + icon = Iconsax.IconsaxForbidden, + destructive = true, + onClick = { onBan(participant.id) }) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt index adff1af3..357bad74 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.modules.jam +import org.spotube.millennium.composables.MillenniumAlertDialog import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import org.spotube.millennium.composables.MillenniumButton @@ -47,27 +48,23 @@ fun PlayDestinationPicker( onSuggestToJam: () -> Unit) { if (!visible) return - MillenniumDialog( + MillenniumAlertDialog( visible = true, onDismissRequest = onDismiss, - title = stringResource(Res.string.jam_destination_title)) { - Text( - text = stringResource(Res.string.jam_destination_message), - style = AppTheme.typography.bodyMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Quiet) { - Text(stringResource(Res.string.common_cancel)) - } - MillenniumButton( - onClick = onPlayLocally, - style = MillenniumButtonStyle.Quiet) { - Text(stringResource(Res.string.jam_destination_play_here)) - } - MillenniumButton(onClick = onSuggestToJam) { - Text(stringResource(Res.string.jam_destination_suggest)) - } + title = { Text(stringResource(Res.string.jam_destination_title)) }, + description = { Text(stringResource(Res.string.jam_destination_message)) }) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = onPlayLocally, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.jam_destination_play_here)) + } + MillenniumButton(onClick = onSuggestToJam) { + Text(stringResource(Res.string.jam_destination_suggest)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index c2f3b405..4c22b818 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -3,6 +3,8 @@ */ package dev.krtirtho.spotube.modules.library.local_tracks +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumSpokeSpinner import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -51,7 +53,6 @@ import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.millenniumPanelBrush @@ -113,7 +114,7 @@ internal fun LocalTracksContent( Modifier.fillMaxSize().padding(bottom = inset), contentAlignment = Alignment.Center ) { - MillenniumIndeterminateProgress() + MillenniumCircularProgress(size = 40.dp) } state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( @@ -264,7 +265,7 @@ internal fun LocalTracksBreadcrumb( style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1 ) - if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp)) + if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp) if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton( onClick = onManageFolders, modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt index 6c84fd81..62819b97 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube.modules.library.playlist +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle @@ -154,34 +155,25 @@ fun AddToPlaylistPicker( } } - if (isLargeScreen) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = stringResource(Res.string.library_add_to_playlist_title), - modifier = modifier) { - Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { - content() - } - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { - Text(stringResource(Res.string.common_cancel)) - } - } - } else { - val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) - MillenniumModalBottomSheet(state = sheetState) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = stringResource(Res.string.library_add_to_playlist_title), - style = AppTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp)) - content() + val pickerTitle = stringResource(Res.string.library_add_to_playlist_title) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + dialogTitle = pickerTitle, + title = { Text(pickerTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_cancel)) + } } + }) { + Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { + content() } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index 7551a157..38c4eb9b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.modules.library.playlist +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import androidx.compose.foundation.clickable import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle @@ -162,90 +163,42 @@ fun PlaylistFormSheet( val handleImagePick = { imagePicker.launch() } - if (isLargeScreen) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title), - modifier = modifier - ) { - PlaylistFormFields( - nameState = nameState, - descriptionState = descriptionState, - isPublic = isPublic, - onIsPublicChange = { isPublic = it }, - isCollaborating = isCollaborating, - onIsCollaboratingChange = { isCollaborating = it }, - imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick - ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + val formTitle = stringResource( + if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + dialogTitle = formTitle, + title = { Text(formTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_cancel)) + } MillenniumButton( onClick = { onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank() - ) { + enabled = name.isNotBlank()) { Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) } - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary - ) { - Text(stringResource(Res.string.common_cancel)) - } } - } - } else { - val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) - MillenniumModalBottomSheet(state = sheetState) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Text( - text = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title), - style = AppTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp) - ) - PlaylistFormFields( - nameState = nameState, - descriptionState = descriptionState, - isPublic = isPublic, - onIsPublicChange = { isPublic = it }, - isCollaborating = isCollaborating, - onIsCollaboratingChange = { isCollaborating = it }, - imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick - ) - Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) - ) { - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary - ) { - Text(stringResource(Res.string.common_cancel)) - } - MillenniumButton( - onClick = { - onSubmit(currentData) - onDismiss() - }, - enabled = name.isNotBlank() - ) { - Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) - } - } - Spacer(modifier = Modifier.height(8.dp)) - } - } + }) { + PlaylistFormFields( + nameState = nameState, + descriptionState = descriptionState, + isPublic = isPublic, + onIsPublicChange = { isPublic = it }, + isCollaborating = isCollaborating, + onIsCollaboratingChange = { isCollaborating = it }, + imageBase64 = imageBase64, + imagePreviewUrl = imagePreviewUrl, + onPickImage = handleImagePick) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index 5bc23b20..24285b5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -18,10 +18,10 @@ package dev.krtirtho.spotube.modules.lyrics +import org.spotube.millennium.composables.MillenniumBarsLoader import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -138,7 +138,7 @@ fun LyricsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - MillenniumIndeterminateProgress(modifier = Modifier.fillMaxWidth(0.4f)) + MillenniumBarsLoader(size = 48.dp) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index dfe87d1d..f50481df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -18,13 +18,15 @@ package dev.krtirtho.spotube.modules.playlist +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement @@ -169,7 +171,7 @@ fun AddTracksToPlaylistDialog( .fillMaxWidth() .weight(1f), contentAlignment = Alignment.Center) { - MillenniumIndeterminateProgress() + MillenniumCircularProgress(size = 40.dp) } } hasSearched && searchResults.isEmpty() -> { @@ -209,33 +211,23 @@ fun AddTracksToPlaylistDialog( } } - if (isLargeScreen) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = stringResource(Res.string.playlist_add_tracks_title), - modifier = modifier) { - Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { - content() - } - MillenniumButton(onClick = onDismiss) { - Text(stringResource(Res.string.playlist_add_tracks_done)) - } - } - } else { - val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) - MillenniumModalBottomSheet(state = sheetState) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .height(600.dp)) { - Text( - text = stringResource(Res.string.playlist_add_tracks_title), - style = AppTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp)) - content() + val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + dialogTitle = dialogTitle, + title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton(onClick = onDismiss) { + Text(stringResource(Res.string.playlist_add_tracks_done)) + } } + }) { + Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { + content() } } } @@ -297,8 +289,7 @@ private fun TrackSearchResultRow( modifier = Modifier.size(24.dp)) } isAdding -> { - MillenniumIndeterminateProgress( - modifier = Modifier.size(24.dp)) + MillenniumSpokeSpinner(size = 24.dp) } else -> { MillenniumButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index 5ddffeb0..ec1a862f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -18,13 +18,15 @@ package dev.krtirtho.spotube.modules.plugin +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumDotsLoader +import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSegmentedButton import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults @@ -85,6 +87,22 @@ import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid import dev.krtirtho.spotube.modules.plugin.components.PluginCard +import dev.krtirtho.spotube.modules.plugin.components.PluginDialogActions +import dev.krtirtho.spotube.modules.plugin.components.PluginDialogBody +import dev.krtirtho.spotube.modules.plugin.components.PluginDialogTitle +import dev.krtirtho.spotube.modules.plugin.components.PluginIconTile +import dev.krtirtho.spotube.modules.plugin.components.PluginLogoTile +import dev.krtirtho.spotube.modules.plugin.components.PluginPanel +import dev.krtirtho.spotube.modules.plugin.components.PluginPill +import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd +import dev.krtirtho.spotube.resources.iconsax.User +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import spotube.composeapp.generated.resources.plugin_install_sheet_subtitle +import spotube.composeapp.generated.resources.plugin_install_file_hint +import spotube.composeapp.generated.resources.plugin_install_or +import spotube.composeapp.generated.resources.plugin_info_dialog_title +import spotube.composeapp.generated.resources.plugin_version_label import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog import dev.krtirtho.spotube.modules.plugin.components.PluginLogo import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog @@ -267,7 +285,7 @@ internal fun PluginScreenContent( Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center - ) { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) } + ) { MillenniumCircularProgress(size = 40.dp) } } is PluginUiState.Data -> { @@ -492,7 +510,7 @@ internal fun PluginScreenContent( Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center ) { - MillenniumIndeterminateProgress(Modifier.widthIn(max = 120.dp)) + MillenniumDotsLoader() } } } @@ -524,7 +542,7 @@ internal fun PluginScreenContent( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 120.dp)) + MillenniumDotsLoader() } } } @@ -606,24 +624,17 @@ private fun InstallSheet( AdaptiveDialogBottomSheet( onDismiss = onDismiss, title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - MillenniumIcon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = AppTheme.colors.primary - ) - Text( - stringResource(Res.string.plugin_install_section_title), - style = AppTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - } - }) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + PluginDialogTitle( + title = stringResource(Res.string.plugin_install_section_title), + icon = Iconsax.IconsaxBoxAdd) + }, + dialogTitle = stringResource(Res.string.plugin_install_section_title)) { + PluginDialogBody { + Text( + stringResource(Res.string.plugin_install_sheet_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + val urlState = rememberTextFieldState(urlInput) LaunchedEffect(urlInput) { if (urlState.text.toString() != urlInput) { @@ -636,51 +647,82 @@ private fun InstallSheet( } } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - MillenniumTextField( - state = urlState, - label = stringResource(Res.string.plugin_section_url_title), - modifier = Modifier.weight(1f), - placeholder = stringResource(Res.string.plugin_url_placeholder), - error = urlError?.text()) - MillenniumIconButton( - onClick = onSubmitUrl, - enabled = !isLoadingUrl, - style = MillenniumIconButtonStyle.Secondary) { - if (isLoadingUrl) { - MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp)) - } else { - MillenniumIcon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = stringResource(Res.string.plugin_action_download)) + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxLink) + Text( + stringResource(Res.string.plugin_section_url_title), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + } + MillenniumTextField( + state = urlState, + label = "", + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.plugin_url_placeholder), + error = urlError?.text()) + MillenniumButton( + onClick = onSubmitUrl, + enabled = !isLoadingUrl, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Primary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (isLoadingUrl) { + MillenniumSpokeSpinner(size = 16.dp, color = MillenniumTheme.colors.onAccent) + } else { + MillenniumIcon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = null, + modifier = Modifier.size(18.dp)) + } + Text(stringResource(Res.string.plugin_action_download)) + } } } } - MillenniumSeparator() - - Text( - stringResource(Res.string.plugin_section_file_title), - style = AppTheme.typography.labelLarge, - color = AppTheme.colors.onSurfaceVariant - ) - MillenniumButton( - onClick = onPickFile, + Row( modifier = Modifier.fillMaxWidth(), - style = MillenniumButtonStyle.Secondary) { + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumSeparator(modifier = Modifier.weight(1f)) + Text( + stringResource(Res.string.plugin_install_or), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + MillenniumSeparator(modifier = Modifier.weight(1f)) + } + + PluginPanel(modifier = Modifier.clickable(onClick = onPickFile)) { Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxExportArrowBulk) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(Res.string.plugin_action_install_from_file), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + Text( + stringResource(Res.string.plugin_install_file_hint), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } MillenniumIcon( - Iconsax.IconsaxExportArrowBulk, - contentDescription = stringResource(Res.string.plugin_action_install_from_file) - ) - Text(stringResource(Res.string.plugin_action_install_from_file)) + FeatherIcons.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) } } } @@ -692,84 +734,82 @@ private fun PluginInfoDialog( plugin: PluginEntry, logoPath: Path?, onDismiss: () -> Unit) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = plugin.name) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(AppTheme.colors.primary.copy(alpha = 0.12f)) - ) { - PluginLogo( - plugin = plugin, - logoPath = logoPath, - contentDescription = plugin.name, - modifier = Modifier.fillMaxSize(), - fallback = { - Box(contentAlignment = Alignment.Center) { - MillenniumIcon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = AppTheme.colors.primary - ) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_info_dialog_title)) }, + dialogTitle = stringResource(Res.string.plugin_info_dialog_title), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_close), + onSecondary = onDismiss) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + PluginLogoTile(plugin = plugin, logoPath = logoPath) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + plugin.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PluginPill(plugin.author, icon = Iconsax.User) + PluginPill( + stringResource(Res.string.plugin_version_label, plugin.version), + tint = AppTheme.colors.primary) + } } - }) - } - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - if (plugin.description.isNotBlank()) { - Text( - plugin.description, - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurface - ) - } - - DetailRow(stringResource(Res.string.plugin_info_version), plugin.version) - DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion) - DetailRow(stringResource(Res.string.plugin_info_author), plugin.author) - if (plugin.license.isNotBlank()) { - DetailRow(stringResource(Res.string.plugin_info_license), plugin.license) - } - - if (plugin.capabilities.isNotEmpty()) { - DetailChipsRow( - stringResource(Res.string.plugin_info_capabilities), - plugin.capabilities.map { it.name }) - } - - if (plugin.abilities.isNotEmpty()) { - DetailChipsRow( - stringResource(Res.string.plugin_info_abilities), - plugin.abilities.map { ability -> ability.displayLabel() }) - } - - if (plugin.repository.isNotBlank()) { - ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) { - openUrlInBrowser(plugin.repository) + } + if (plugin.description.isNotBlank()) { + Text( + plugin.description, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } - if (plugin.contact.isNotBlank()) { - DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact) - } - - if (plugin.bugs.isNotBlank()) { - ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) { - openUrlInBrowser(plugin.bugs) + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion) + if (plugin.license.isNotBlank()) { + DetailRow(stringResource(Res.string.plugin_info_license), plugin.license) + } + if (plugin.capabilities.isNotEmpty()) { + DetailChipsRow( + stringResource(Res.string.plugin_info_capabilities), + plugin.capabilities.map { it.name }) + } + if (plugin.abilities.isNotEmpty()) { + DetailChipsRow( + stringResource(Res.string.plugin_info_abilities), + plugin.abilities.map { ability -> ability.displayLabel() }) + } + if (plugin.repository.isNotBlank()) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) { + openUrlInBrowser(plugin.repository) + } + } + if (plugin.contact.isNotBlank()) { + DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact) + } + if (plugin.bugs.isNotBlank()) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) { + openUrlInBrowser(plugin.bugs) + } + } } } } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton(onClick = onDismiss) { - Text(stringResource(Res.string.common_close)) - } - } } } @@ -779,27 +819,30 @@ private fun SupportDialog( supportText: String?, isLoading: Boolean, onDismiss: () -> Unit) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = stringResource(Res.string.plugin_support_dialog_title, plugin.name)) { - if (isLoading) { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), - contentAlignment = Alignment.Center - ) { - MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) - } - } else { - Text( - supportText ?: "", - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurface - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton(onClick = onDismiss) { - Text(stringResource(Res.string.common_close)) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_support_dialog_title, plugin.name)) }, + dialogTitle = stringResource(Res.string.plugin_support_dialog_title, plugin.name), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_close), + onSecondary = onDismiss) + }) { + PluginDialogBody { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 36.dp) + } + } else { + PluginPanel { + Text( + supportText ?: "", + modifier = Modifier.padding(14.dp), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface) + } } } } @@ -972,7 +1015,7 @@ private fun DiscoverRepoCard( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (isInstalling) { - MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp)) + MillenniumSpokeSpinner(size = 16.dp) } else { MillenniumIcon( Iconsax.IconsaxAdd, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginDialogParts.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginDialogParts.kt new file mode 100644 index 00000000..dd114789 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginDialogParts.kt @@ -0,0 +1,249 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.plugin.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +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.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import dev.krtirtho.spotube.modules.plugin.PluginEntry +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd +import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation +import okio.Path +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface + +// Shared building blocks for the plugin dialogs/sheets so they all read as one family and +// match the panel/tile language used by the plugin cards and the rest of the app. + +/** Body column for plugin dialogs; the adaptive dialog/sheet around it handles scrolling. */ +@Composable +internal fun PluginDialogBody(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content) +} + +/** Title for the phone bottom sheet; the desktop dialog shows the same text in its titlebar. */ +@Composable +internal fun PluginDialogTitle( + title: String, + icon: ImageVector? = null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (icon != null) { + PluginIconTile(icon, size = 40.dp) + } + Text( + title, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface) + } +} + +/** Rounded, softly bordered panel — the same surface the plugin cards use. */ +@Composable +internal fun PluginPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier + .fillMaxWidth() + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp), + content = content) +} + +@Composable +internal fun PluginIconTile( + icon: ImageVector, + modifier: Modifier = Modifier, + size: Dp = 38.dp, + tint: Color = AppTheme.colors.primary) { + Box( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(size / 3.4f)) + .background(tint.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(size * 0.5f), + tint = tint) + } +} + +@Composable +internal fun PluginLogoTile( + plugin: PluginEntry, + logoPath: Path?, + size: Dp = 56.dp) { + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(size / 3.4f)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center) { + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = Iconsax.IconsaxBoxAdd, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(size * 0.5f)) + }) + } +} + +@Composable +internal fun PluginPill( + text: String, + icon: ImageVector? = null, + tint: Color = AppTheme.colors.onSurfaceVariant) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(tint.copy(alpha = 0.12f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (icon != null) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = tint) + } + Text( + text, + style = AppTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } +} + +/** Tinted info strip; [isWarning] switches it to the error tone. */ +@Composable +internal fun PluginCallout(text: String, isWarning: Boolean = false) { + val tint = if (isWarning) AppTheme.colors.error else AppTheme.colors.primary + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(tint.copy(alpha = 0.10f)) + .padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxInformation, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = tint) + Text( + text, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurface) + } +} + +@Composable +internal fun PluginSectionLabel(text: String, badge: String? = null) { + Row( + modifier = Modifier.padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text, + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface) + if (badge != null) { + PluginPill(badge, tint = AppTheme.colors.primary) + } + } +} + +/** Equal-width secondary + primary action pair. */ +@Composable +internal fun PluginDialogActions( + secondaryLabel: String, + onSecondary: () -> Unit, + primaryLabel: String? = null, + onPrimary: (() -> Unit)? = null, + primaryEnabled: Boolean = true) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onSecondary, + modifier = Modifier.weight(1f), + style = if (primaryLabel != null) MillenniumButtonStyle.Secondary else MillenniumButtonStyle.Primary) { + Text(secondaryLabel) + } + if (primaryLabel != null && onPrimary != null) { + MillenniumButton( + onClick = onPrimary, + enabled = primaryEnabled, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Primary) { + Text(primaryLabel) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt index f598b5c6..e8eebdef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -17,7 +17,7 @@ package dev.krtirtho.spotube.modules.plugin.components -import androidx.compose.foundation.background +import org.spotube.millennium.composables.MillenniumCircularProgress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,10 +32,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll - -import dev.krtirtho.spotube.core.ui.theming.AppTheme - -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -52,19 +48,22 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade -import dev.krtirtho.spotube.modules.plugin.GitHubRepo +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.plugin.GitHubRelease +import dev.krtirtho.spotube.modules.plugin.GitHubRepo import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxBox import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuContent +import org.spotube.millennium.composables.MillenniumDropdownMenuItem import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel import spotube.composeapp.generated.resources.plugin_action_install import spotube.composeapp.generated.resources.plugin_install_no_releases import spotube.composeapp.generated.resources.plugin_install_prerelease @@ -88,21 +87,24 @@ fun PluginInstallDialog( } } - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = repo.fullName) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(AppTheme.colors.primary.copy(alpha = 0.12f)) - ) { - Box(contentAlignment = Alignment.Center) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_action_install)) }, + dialogTitle = stringResource(Res.string.plugin_action_install), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_cancel), + onSecondary = onDismiss, + primaryLabel = stringResource(Res.string.plugin_action_install), + onPrimary = { selectedRelease?.let(onInstall) }, + primaryEnabled = selectedRelease != null && !isLoadingReleases) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { val platformContext = LocalPlatformContext.current AsyncImage( model = ImageRequest.Builder(platformContext) @@ -110,131 +112,99 @@ fun PluginInstallDialog( .crossfade(true) .build(), contentDescription = repo.owner.login, - modifier = Modifier.fillMaxSize() - ) - } - } - Column(modifier = Modifier.weight(1f)) { - Text( - repo.fullName, - style = AppTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (!repo.description.isNullOrBlank()) { - Text( - repo.description, - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - if (isLoadingReleases) { - Box( - modifier = Modifier.fillMaxWidth().height(120.dp), - contentAlignment = Alignment.Center - ) { - MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) - } - } else if (releases.isEmpty()) { - Text( - stringResource(Res.string.plugin_install_no_releases), - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant - ) - } else { - Text( - stringResource(Res.string.plugin_install_select_release_label), - style = AppTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = AppTheme.colors.onSurface - ) - - MillenniumDropdownMenu( - expanded = expanded, - onExpandedChange = { expanded = it }, - items = releases.map { it.tagName }, - onItemClick = { tagName -> - selectedRelease = releases.firstOrNull { it.tagName == tagName } - expanded = false - }, - modifier = Modifier.fillMaxWidth(), - anchor = { - MillenniumButton( - onClick = { expanded = true }, - modifier = Modifier.fillMaxWidth(), - style = MillenniumButtonStyle.Secondary - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp))) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + repo.fullName, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + if (!repo.description.isNullOrBlank()) { Text( - selectedRelease?.tagName - ?: stringResource(Res.string.plugin_install_select_release_placeholder), - style = AppTheme.typography.bodyMedium - ) - MillenniumIcon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - } - } - ) - - selectedRelease?.let { release -> - if (release.prerelease) { - Text( - stringResource(Res.string.plugin_install_prerelease), - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.error - ) - } - if (!release.body.isNullOrBlank()) { - Text( - stringResource(Res.string.plugin_install_release_notes), - style = AppTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = AppTheme.colors.onSurface - ) - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 200.dp) - .verticalScroll(rememberScrollState()) - .padding(12.dp) - ) { - Text( - release.body, + repo.description, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant - ) + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis) } } } } - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton( - onClick = { selectedRelease?.let { onInstall(it) } }, - enabled = selectedRelease != null && !isLoadingReleases - ) { - Text(stringResource(Res.string.plugin_action_install)) + if (isLoadingReleases) { + Box( + modifier = Modifier.fillMaxWidth().height(96.dp), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 36.dp) + } + } else if (releases.isEmpty()) { + PluginCallout(stringResource(Res.string.plugin_install_no_releases), isWarning = true) + } else { + PluginSectionLabel(stringResource(Res.string.plugin_install_select_release_label)) + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth(), + anchor = { + MillenniumButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text( + selectedRelease?.tagName + ?: stringResource(Res.string.plugin_install_select_release_placeholder), + style = AppTheme.typography.bodyMedium) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } + } + }) { + MillenniumDropdownMenuContent { + releases.forEach { release -> + MillenniumDropdownMenuItem( + label = release.tagName, + selected = release.tagName == selectedRelease?.tagName, + description = if (release.prerelease) { + stringResource(Res.string.plugin_install_prerelease) + } else null, + onClick = { + selectedRelease = release + expanded = false + }) + } + } + } + + selectedRelease?.let { release -> + if (release.prerelease) { + PluginCallout(stringResource(Res.string.plugin_install_prerelease), isWarning = true) + } + if (!release.body.isNullOrBlank()) { + PluginSectionLabel(stringResource(Res.string.plugin_install_release_notes)) + PluginPanel { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + .padding(12.dp)) { + Text( + release.body, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt index 5c385c41..bf2754d9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -17,47 +17,39 @@ package dev.krtirtho.spotube.modules.plugin.components - -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumCard -import org.spotube.millennium.composables.MillenniumDialog -import org.spotube.millennium.composables.MillenniumIcon -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import okio.Path +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ArrowRight +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.plugin.PluginCapability import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxEye import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare import dev.krtirtho.spotube.resources.iconsax.PhosphorDatabaseDuotone import dev.krtirtho.spotube.resources.iconsax.User +import okio.Path import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.common_close import spotube.composeapp.generated.resources.plugin_permissions_api_diff -import spotube.composeapp.generated.resources.plugin_permissions_author_version import spotube.composeapp.generated.resources.plugin_permissions_capability_network_desc import spotube.composeapp.generated.resources.plugin_permissions_capability_network_title import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_desc @@ -70,8 +62,6 @@ import spotube.composeapp.generated.resources.plugin_permissions_none import spotube.composeapp.generated.resources.plugin_permissions_requested_title import spotube.composeapp.generated.resources.plugin_permissions_supplied_label import spotube.composeapp.generated.resources.plugin_version_label -import spotube.composeapp.generated.resources.common_cancel -import spotube.composeapp.generated.resources.common_close @Composable fun PluginPermissionDialog( @@ -83,129 +73,80 @@ fun PluginPermissionDialog( logoPath: Path? = null, onConfirm: (() -> Unit)? = null, onDismiss: () -> Unit) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = title) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(AppTheme.colors.primary.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center - ) { - PluginLogo( - plugin = pluginInfo, - logoPath = logoPath, - contentDescription = pluginInfo.name, - modifier = Modifier.fillMaxSize(), - fallback = { - MillenniumIcon( - imageVector = Iconsax.IconsaxBoxAdd, - contentDescription = null, - tint = AppTheme.colors.primary, - modifier = Modifier.size(24.dp) - ) - }) - } - Column(modifier = Modifier.weight(1f)) { - Text( - pluginInfo.name, - style = AppTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - MillenniumIcon( - Iconsax.User, - contentDescription = null, - modifier = Modifier.size(13.dp), - tint = AppTheme.colors.onSurfaceVariant - ) - Text( - pluginInfo.author, - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant - ) - Text( - stringResource( - Res.string.plugin_permissions_author_version, - stringResource(Res.string.plugin_version_label, pluginInfo.version) - ), - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant - ) - } - - if (pluginInfo.description.isNotBlank()) { - Text( - pluginInfo.description, - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - MillenniumCard(contentPadding = PaddingValues(14.dp)) { - Text( - message, - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurface - ) - } - - existingPlugin?.let { - InstalledComparisonCard( - installedPlugin = it, - incomingPlugin = pluginInfo - ) - } - - Text( - stringResource(Res.string.plugin_permissions_requested_title), - style = AppTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = AppTheme.colors.onSurface - ) - - if (pluginInfo.capabilities.isEmpty()) { - Text( - stringResource(Res.string.plugin_permissions_none), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant - ) - } else { - pluginInfo.capabilities.forEach { capability -> - CapabilityRow(capability) - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { - Text( - if (confirmLabel == null) { - stringResource(Res.string.common_close) - } else { - stringResource(Res.string.common_cancel) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(title) }, + dialogTitle = title, + actions = { + PluginDialogActions( + secondaryLabel = stringResource( + if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), + onSecondary = onDismiss, + primaryLabel = confirmLabel, + onPrimary = onConfirm) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + PluginLogoTile(plugin = pluginInfo, logoPath = logoPath) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + pluginInfo.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PluginPill(pluginInfo.author, icon = Iconsax.User) + PluginPill( + stringResource(Res.string.plugin_version_label, pluginInfo.version), + tint = AppTheme.colors.primary) + } } - ) + } + if (pluginInfo.description.isNotBlank()) { + Text( + pluginInfo.description, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis) + } } - if (confirmLabel != null && onConfirm != null) { - MillenniumButton(onClick = onConfirm) { - Text(confirmLabel) + + PluginCallout(text = message, isWarning = confirmLabel == null) + + existingPlugin?.let { + InstalledComparisonPanel(installedPlugin = it, incomingPlugin = pluginInfo) + } + + PluginSectionLabel( + stringResource(Res.string.plugin_permissions_requested_title), + badge = pluginInfo.capabilities.size.takeIf { it > 0 }?.toString()) + + PluginPanel { + if (pluginInfo.capabilities.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxCheckCircle) + Text( + stringResource(Res.string.plugin_permissions_none), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } else { + pluginInfo.capabilities.forEachIndexed { index, capability -> + if (index > 0) MillenniumSeparator() + CapabilityRow(capability) + } } } } @@ -213,57 +154,58 @@ fun PluginPermissionDialog( } @Composable -private fun InstalledComparisonCard( +private fun InstalledComparisonPanel( installedPlugin: PluginEntry, incomingPlugin: PluginEntry) { - MillenniumCard( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(14.dp)) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - stringResource(Res.string.plugin_permissions_compare_title), - style = AppTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - VersionStat( - label = stringResource(Res.string.plugin_permissions_installed_label), - value = installedPlugin.version) - VersionStat( - label = stringResource(Res.string.plugin_permissions_supplied_label), - value = incomingPlugin.version) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PluginSectionLabel(stringResource(Res.string.plugin_permissions_compare_title)) + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly) { + VersionStat( + label = stringResource(Res.string.plugin_permissions_installed_label), + value = installedPlugin.version) + MillenniumIcon( + imageVector = FeatherIcons.ArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) + VersionStat( + label = stringResource(Res.string.plugin_permissions_supplied_label), + value = incomingPlugin.version, + highlighted = true) + } + Text( + stringResource( + Res.string.plugin_permissions_api_diff, + installedPlugin.apiVersion, + incomingPlugin.apiVersion), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } - Text( - stringResource( - Res.string.plugin_permissions_api_diff, - installedPlugin.apiVersion, - incomingPlugin.apiVersion - ), - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant - ) } } } @Composable -private fun VersionStat(label: String, value: String) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { +private fun VersionStat(label: String, value: String, highlighted: Boolean = false) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( label, style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant - ) + color = AppTheme.colors.onSurfaceVariant) Text( stringResource(Res.string.plugin_version_label, value), - style = AppTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (highlighted) AppTheme.colors.primary else AppTheme.colors.onSurface) } } @@ -273,54 +215,31 @@ private fun CapabilityRow(capability: PluginCapability) { PluginCapability.PERSISTENT_STORAGE -> Triple( Iconsax.PhosphorDatabaseDuotone, stringResource(Res.string.plugin_permissions_capability_storage_title), - stringResource(Res.string.plugin_permissions_capability_storage_desc) - ) + stringResource(Res.string.plugin_permissions_capability_storage_desc)) PluginCapability.NETWORK_REQUESTS -> Triple( Iconsax.IconsaxWifiSquare, stringResource(Res.string.plugin_permissions_capability_network_title), - stringResource(Res.string.plugin_permissions_capability_network_desc) - ) + stringResource(Res.string.plugin_permissions_capability_network_desc)) PluginCapability.WEBVIEW -> Triple( Iconsax.IconsaxEye, stringResource(Res.string.plugin_permissions_capability_webview_title), - stringResource(Res.string.plugin_permissions_capability_webview_desc) - ) + stringResource(Res.string.plugin_permissions_capability_webview_desc)) } - MillenniumCard( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) - ) { - Box( - modifier = Modifier - .size(34.dp) - .clip(CircleShape) - .background(AppTheme.colors.errorContainer.copy(alpha = 0.6f)), - contentAlignment = Alignment.Center - ) { - MillenniumIcon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = AppTheme.colors.error - ) - } - Column { - Text( - label, - style = AppTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - description, - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant - ) - } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(icon) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + label, + style = AppTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium) + Text( + description, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 24d466c6..7352a399 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -18,10 +18,10 @@ package dev.krtirtho.spotube.modules.search +import org.spotube.millennium.composables.MillenniumMarqueeProgress import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumAutocomplete import org.spotube.millennium.composables.MillenniumText @@ -1155,7 +1155,7 @@ private fun SearchLoadingIndicator( translationY = bounce }) } - MillenniumIndeterminateProgress( + MillenniumMarqueeProgress( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp, vertical = 16.dp) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt index 40095a64..1316530f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt @@ -18,10 +18,10 @@ package dev.krtirtho.spotube.modules.settings +import org.spotube.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSidebarItem import org.spotube.millennium.theme.MillenniumTheme @@ -288,7 +288,7 @@ private fun SettingsTabContent( Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - MillenniumIndeterminateProgress() + MillenniumCircularProgress(size = 40.dp) } } else { Box(modifier = Modifier.fillMaxSize()) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index abdecb8c..ba54f3bf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -18,6 +18,12 @@ package dev.krtirtho.spotube.modules.settings.components +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumDialogDescription import androidx.compose.foundation.layout.Box import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints @@ -238,26 +244,30 @@ internal fun TextInputSettingCard( val normalizedValue = normalize(draft) val errorMessage = validate(normalizedValue) - MillenniumDialog( - visible = true, - onDismissRequest = { isDialogOpen = false }, - title = dialogTitle - ) { - if (dialogDescription != null) { - Text( - text = dialogDescription, - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant + MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { + MillenniumDialogHeader { + MillenniumDialogTitle(dialogTitle) + MillenniumDialogClose() + } + MillenniumDialogBody { + if (dialogDescription != null) { + MillenniumDialogDescription(dialogDescription) + } + MillenniumTextField( + state = dialogState, + label = title, + modifier = Modifier.fillMaxWidth(), + placeholder = placeholder, + error = errorMessage ) } - MillenniumTextField( - state = dialogState, - label = title, - modifier = Modifier.fillMaxWidth(), - placeholder = placeholder, - error = errorMessage - ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumDialogFooter { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.common_cancel)) + } MillenniumButton( onClick = { if (errorMessage == null) { @@ -269,12 +279,6 @@ internal fun TextInputSettingCard( ) { Text(stringResource(Res.string.common_save)) } - MillenniumButton( - onClick = { isDialogOpen = false }, - style = MillenniumButtonStyle.Secondary - ) { - Text(stringResource(Res.string.common_cancel)) - } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index 7337ad09..b3d4d0e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -17,6 +17,11 @@ package dev.krtirtho.spotube.modules.settings.sections +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -151,10 +156,12 @@ private fun AppearanceSettingCard( mutableStateOf(selectedPalette) } - MillenniumDialog( - visible = true, - onDismissRequest = { isDialogOpen = false }, - title = stringResource(Res.string.settings_palette_dialog_title)) { + MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.settings_palette_dialog_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { MillenniumText( stringResource(Res.string.settings_palette_dialog_description), color = AppTheme.colors.onSurfaceVariant) @@ -174,9 +181,13 @@ private fun AppearanceSettingCard( } PalettePreview(draftPalette) - - MillenniumSeparator() - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + } + MillenniumDialogFooter { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.common_cancel)) + } MillenniumButton( onClick = { onSaved(draftPalette) @@ -184,11 +195,6 @@ private fun AppearanceSettingCard( }) { MillenniumText(stringResource(Res.string.common_save)) } - MillenniumButton( - onClick = { isDialogOpen = false }, - style = MillenniumButtonStyle.Secondary) { - MillenniumText(stringResource(Res.string.common_cancel)) - } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 1586083d..09f28848 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -18,6 +18,10 @@ package dev.krtirtho.spotube.modules.shell +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd +import dev.krtirtho.spotube.resources.iconsax.IconsaxMoon +import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -436,27 +440,6 @@ fun AppLargePlayer( onExpandedChange = { expanded -> if (!expanded) playerOptionsViewModel.dismissMoreOptionsMenu() }, - items = listOf(goToAlbumLabel, sleepTimerLabel, trackDetailsLabel), - onItemClick = { label -> - when (label) { - goToAlbumLabel -> { - playerOptionsViewModel.dismissMoreOptionsMenu() - currentTrack?.album?.id?.let { albumId -> - navigationCommands.navigateTo(Routes.Album(albumId)) - } - } - - sleepTimerLabel -> { - playerOptionsViewModel.dismissMoreOptionsMenu() - playerOptionsViewModel.showSleepTimerDialog() - } - - trackDetailsLabel -> { - playerOptionsViewModel.dismissMoreOptionsMenu() - playerOptionsViewModel.showTrackDetails() - } - } - }, side = AnchorSide.Top, anchor = { MillenniumIconButton( @@ -471,7 +454,31 @@ fun AppLargePlayer( contentDescription = stringResource(Res.string.player_more_options_content_description) ) } - }) + }) { + MillenniumDropdownMenuItem( + label = goToAlbumLabel, + icon = Iconsax.IconsaxCd, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + currentTrack?.album?.id?.let { albumId -> + navigationCommands.navigateTo(Routes.Album(albumId)) + } + }) + MillenniumDropdownMenuItem( + label = sleepTimerLabel, + icon = Iconsax.IconsaxMoon, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showSleepTimerDialog() + }) + MillenniumDropdownMenuItem( + label = trackDetailsLabel, + icon = Iconsax.IconsaxInformation, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showTrackDetails() + }) + } } Row(verticalAlignment = Alignment.CenterVertically) { MillenniumIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index e89b40bb..e613d25b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -21,6 +21,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -166,7 +167,11 @@ fun AppSidebar( } } } - MillenniumSidebarFooter { + // Compact the footer chrome only (padding + gap between items); items keep their size. + MillenniumSidebarFooter( + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp), + contentSpacing = 2.dp + ) { val supportLabel = stringResource(Res.string.support_button_label) MillenniumSidebarItem( label = supportLabel, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt index 3529f6ab..d8098f6d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt @@ -18,11 +18,16 @@ package dev.krtirtho.spotube.modules.shell +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumMarqueeProgress import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumDialog -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.theme.millenniumSurface import androidx.compose.foundation.text.input.rememberTextFieldState @@ -128,11 +133,12 @@ private fun SleepTimerDialog( onStart: () -> Unit, onCancelTimer: () -> Unit, onDismiss: () -> Unit) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = stringResource(Res.string.player_sleep_timer)) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.player_sleep_timer)) + MillenniumDialogClose() + } + MillenniumDialogBody { if (remainingMs != null) { Text( text = stringResource(Res.string.player_sleep_timer_remaining, formatRemainingTime(remainingMs)), @@ -152,21 +158,20 @@ private fun SleepTimerDialog( label = stringResource(Res.string.player_sleep_timer_minutes_label), placeholder = stringResource(Res.string.player_sleep_timer_minutes_placeholder), modifier = Modifier.fillMaxWidth()) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (remainingMs != null) { - MillenniumButton( - onClick = onCancelTimer, - style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.player_sleep_timer_cancel)) } - } + } + MillenniumDialogFooter { + if (remainingMs != null) { MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } - MillenniumButton( - onClick = onStart, - enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) { - Text(stringResource(Res.string.player_sleep_timer_start)) - } + onClick = onCancelTimer, + style = MillenniumButtonStyle.Quiet) { Text(stringResource(Res.string.player_sleep_timer_cancel)) } + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } + MillenniumButton( + onClick = onStart, + enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) { + Text(stringResource(Res.string.player_sleep_timer_start)) } } } @@ -176,11 +181,12 @@ private fun SleepTimerDialog( private fun TrackDetailsDialog( details: PlayerTrackDetails, onDismiss: () -> Unit) { - MillenniumDialog( - visible = true, - onDismissRequest = onDismiss, - title = stringResource(Res.string.player_track_details)) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.player_track_details)) + MillenniumDialogClose() + } + MillenniumDialogBody(contentSpacing = 14.dp) { Text( details.track.title, style = AppTheme.typography.bodyMedium, @@ -189,8 +195,7 @@ private fun TrackDetailsDialog( Row( modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), horizontalArrangement = Arrangement.Center) { - MillenniumIndeterminateProgress( - modifier = Modifier.fillMaxWidth()) + MillenniumMarqueeProgress(modifier = Modifier.fillMaxWidth()) } } @@ -315,6 +320,8 @@ private fun TrackDetailsDialog( DetailRow(stringResource(Res.string.player_track_details_active_url), activeUrl) } + } + MillenniumDialogFooter { MillenniumButton( onClick = onDismiss, style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt index 129b6ea1..d1303959 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -18,8 +18,8 @@ package dev.krtirtho.spotube.modules.shell.alternative_track +import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -98,8 +98,7 @@ fun AlternativeTrackContent( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - MillenniumIndeterminateProgress( - modifier = Modifier.size(24.dp)) + MillenniumSpokeSpinner(size = 24.dp) Spacer(modifier = Modifier.width(12.dp)) Text( text = stringResource(Res.string.alternative_track_searching), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 67fbf043..9c4c7685 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube.modules.shell.player_queue +import org.spotube.millennium.composables.MillenniumDropdownMenuItem import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -419,11 +420,6 @@ private fun QueueItemRow( MillenniumDropdownMenu( expanded = showMenu, onExpandedChange = { showMenu = it }, - items = listOf(stringResource(Res.string.queue_remove_from_queue)), - onItemClick = { - onRemoveClick() - showMenu = false - }, anchor = { MillenniumIconButton( onClick = { showMenu = true }, @@ -434,7 +430,16 @@ private fun QueueItemRow( contentDescription = stringResource(Res.string.player_more_options_content_description) ) } - }) + }) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.queue_remove_from_queue), + icon = Iconsax.IconsaxMusicSquareRemove, + destructive = true, + onClick = { + onRemoveClick() + showMenu = false + }) + } } } ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt index fc1c59a8..51486546 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt @@ -17,6 +17,12 @@ package dev.krtirtho.spotube.modules.support +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogDescription +import org.spotube.millennium.composables.MillenniumDialogFooter import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -103,49 +109,50 @@ fun SupportDialog( onDismissRequest: () -> Unit, isReminder: Boolean = false) { if (!visible) return - MillenniumDialog( - visible = true, - onDismissRequest = onDismissRequest, - title = stringResource(Res.string.support_dialog_title)) { - Text( - stringResource(Res.string.support_dialog_message), - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) - if (isReminder) { - Text( - stringResource(Res.string.support_dialog_reminder_hint), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + MillenniumDialog(visible = true, onDismissRequest = onDismissRequest) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.support_dialog_title)) + MillenniumDialogClose() } - donationOptions.forEach { option -> - MillenniumButton( - onClick = { - openDonation(option) - onDismissRequest() - }, - modifier = Modifier.fillMaxWidth(), - style = MillenniumButtonStyle.Secondary) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MillenniumIcon( - imageVector = option.icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = if (option.id == "patreon") { - AppTheme.colors.onSurface - } else { - Color.Unspecified - }) - Text(stringResource(option.title)) + MillenniumDialogBody { + MillenniumDialogDescription(stringResource(Res.string.support_dialog_message)) + if (isReminder) { + Text( + stringResource(Res.string.support_dialog_reminder_hint), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + donationOptions.forEach { option -> + MillenniumButton( + onClick = { + openDonation(option) + onDismissRequest() + }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = option.icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (option.id == "patreon") { + AppTheme.colors.onSurface + } else { + Color.Unspecified + }) + Text(stringResource(option.title)) + } } } } - MillenniumButton( - onClick = onDismissRequest, - modifier = Modifier.fillMaxWidth(), - style = MillenniumButtonStyle.Quiet) { - Text(stringResource(Res.string.support_dialog_dismiss)) + MillenniumDialogFooter { + MillenniumButton( + onClick = onDismissRequest, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.support_dialog_dismiss)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt index 6cd60adf..d894ef08 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt @@ -17,19 +17,14 @@ package dev.krtirtho.spotube.modules.update -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer -import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import com.composeunstyled.Text import com.mikepenz.markdown.m3.Markdown import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumAlertDialog import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumDialog import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.app_update_dialog_no_notes import spotube.composeapp.generated.resources.app_update_dialog_title @@ -47,28 +42,26 @@ fun AppUpdateDialog( onDismiss: () -> Unit, onIgnore: () -> Unit, onUpdate: () -> Unit) { - MillenniumDialog( + MillenniumAlertDialog( visible = true, onDismissRequest = onDismiss, - title = stringResource(Res.string.app_update_dialog_title, update.tag)) { - if (update.releaseNotesMarkdown.isNotBlank()) { - SelectionContainer { - Markdown( - content = update.releaseNotesMarkdown, - modifier = Modifier.padding(bottom = 8.dp)) + title = { Text(stringResource(Res.string.app_update_dialog_title, update.tag)) }, + content = { + if (update.releaseNotesMarkdown.isNotBlank()) { + SelectionContainer { + Markdown(content = update.releaseNotesMarkdown) + } + } else { + Text(stringResource(Res.string.app_update_dialog_no_notes)) } - } else { - Text(stringResource(Res.string.app_update_dialog_no_notes)) + }) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton( - onClick = onIgnore, - style = MillenniumButtonStyle.Secondary) { - Text(stringResource(Res.string.update_action_ignore)) - } - MillenniumButton(onClick = onUpdate) { - Text(stringResource(Res.string.update_action_update)) - } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt index 0e1e9f50..8b303d20 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.modules.update +import org.spotube.millennium.composables.MillenniumAlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -30,7 +31,6 @@ import com.mikepenz.markdown.m3.Markdown import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumDialog import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_update_dialog_current_version import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes @@ -50,33 +50,35 @@ fun PluginUpdateDialog( onDismiss: () -> Unit, onIgnore: () -> Unit, onUpdate: () -> Unit) { - MillenniumDialog( + MillenniumAlertDialog( visible = true, onDismissRequest = onDismiss, - title = stringResource( - Res.string.plugin_update_dialog_title, - update.pluginName, - update.info.latestVersion)) { - Text( - text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion), - color = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp)) - if (update.info.releaseNotes.isNotBlank()) { - SelectionContainer { - Markdown(content = update.info.releaseNotes) + title = { + Text( + stringResource( + Res.string.plugin_update_dialog_title, + update.pluginName, + update.info.latestVersion)) + }, + description = { + Text(stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion)) + }, + content = { + if (update.info.releaseNotes.isNotBlank()) { + SelectionContainer { + Markdown(content = update.info.releaseNotes) + } + } else { + Text(stringResource(Res.string.plugin_update_dialog_no_notes)) } - } else { - Text(stringResource(Res.string.plugin_update_dialog_no_notes)) + }) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton( - onClick = onIgnore, - style = MillenniumButtonStyle.Secondary) { - Text(stringResource(Res.string.update_action_ignore)) - } - MillenniumButton(onClick = onUpdate) { - Text(stringResource(Res.string.update_action_update)) - } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) } } } From 59f1b82368ac00747e81548db738c2187d2154af Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 12:01:36 +0600 Subject: [PATCH 055/132] feat(ui): update MillenniumModalBottomSheet with compact padding and spacing adjustments --- .../ui/component/AdaptiveDialogBottomSheet.kt | 7 +---- .../component/AdaptiveDropdownBottomSheet.kt | 6 ++++- .../spotube/core/ui/component/TrackOptions.kt | 6 ++++- .../spotube/core/ui/theming/AppTheme.kt | 26 +++++++++++++++++-- .../modules/shell/AppExpandedPlayer.kt | 3 ++- .../modules/shell/player_queue/QueueSheet.kt | 3 ++- 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt index d35a8c38..8b713c37 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -40,7 +40,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import com.composeunstyled.SheetDetent import com.composeunstyled.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -94,10 +93,7 @@ fun AdaptiveDialogBottomSheet( } MillenniumModalBottomSheet(state = sheetState) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 16.dp), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { title?.let { Row( @@ -130,7 +126,6 @@ fun AdaptiveDialogBottomSheet( it() } } - Spacer(Modifier.height(24.dp)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index d66044b2..be7f2ea6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.widthIn @@ -165,7 +166,10 @@ private fun AdaptiveBottomSheetContent( onDismiss() } } - MillenniumModalBottomSheet(state = sheetState) { + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp), + contentSpacing = 4.dp) { Column( modifier = Modifier.fillMaxWidth()) { header?.invoke() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index 775d726a..b2b2dadc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -144,7 +145,10 @@ fun TrackOptionsBottomSheet( onDismiss() } } - MillenniumModalBottomSheet(state = sheetState) { + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp), + contentSpacing = 4.dp) { Column(modifier = Modifier.fillMaxWidth()) { TrackOptionsSheetHeader(track = track) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index b20a2ede..15f74e89 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -18,6 +18,7 @@ package dev.krtirtho.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 import androidx.compose.runtime.CompositionLocalProvider @@ -35,9 +36,11 @@ import androidx.compose.ui.unit.sp import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.millennium.composables.MillenniumModalBottomSheetTheme import org.spotube.millennium.composables.MillenniumTooltipHost import org.spotube.millennium.composables.MillenniumTopAppBarTheme import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties +import org.spotube.millennium.composables.defaultMillenniumModalBottomSheetTheme import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme import org.spotube.millennium.theme.MillenniumColors import org.spotube.millennium.theme.MillenniumDarkThemeComponents @@ -218,14 +221,16 @@ fun SpotubeTheme( properties = darkTheme.topAppBarTheme.properties.copy( separatorColor = Color.Transparent ) - ) + ), + modalBottomSheetTheme = compactModalBottomSheetTheme() ), light = lightTheme.copy( topAppBarTheme = MillenniumTopAppBarTheme( properties = lightTheme.topAppBarTheme.properties.copy( separatorColor = Color.Transparent ) - ) + ), + modalBottomSheetTheme = compactModalBottomSheetTheme() ) ) ) { @@ -241,6 +246,23 @@ fun SpotubeTheme( } } +/** Library default is 24dp padding + 16dp spacing, which is far too roomy on phones. */ +private fun compactModalBottomSheetTheme(): MillenniumModalBottomSheetTheme { + val padding = PaddingValues(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp) + fun org.spotube.millennium.composables.MillenniumModalBottomSheetThemeProperties.compact() = + copy(contentPadding = padding, contentSpacing = 8.dp) + val states = defaultMillenniumModalBottomSheetTheme().states + return MillenniumModalBottomSheetTheme( + states = org.spotube.millennium.theme.MillenniumControlStateProperty( + default = states.default.compact(), + hovered = states.hovered.compact(), + focused = states.focused.compact(), + pressed = states.pressed.compact(), + disabled = states.disabled.compact(), + selected = states.selected.compact(), + error = states.error.compact())) +} + /** Whether the active Millennium appearance is light. */ @Composable fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index f869f920..22208484 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -289,7 +289,8 @@ fun AppExpandedPlayer( if (playerOptionsUiState.isMoreOptionsSheetOpen) { MillenniumModalBottomSheet( state = moreOptionsSheetState, - contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp) + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp), + contentSpacing = 4.dp ) { // Grid of Options LazyVerticalGrid( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index 207bec37..5d566a64 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -99,7 +99,8 @@ internal fun BottomSheetContent( } MillenniumModalBottomSheet( state = sheetState, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + contentSpacing = 8.dp ) { content() } From 83115ea99a2ba29f1e1edd67128e6d0084eeecc3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 12:06:13 +0600 Subject: [PATCH 056/132] feat(plugin): add login prompt for metadata plugin access and implement authentication view model --- .../composeResources/values/strings.xml | 6 ++ .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../spotube/core/ui/component/ErrorDisplay.kt | 24 +++-- .../core/ui/component/LoginRequiredDisplay.kt | 101 ++++++++++++++++++ .../spotube/modules/library/LibraryScreen.kt | 6 +- .../modules/plugin/MetadataAuthViewModel.kt | 80 ++++++++++++++ .../plugin/guard/MetadataPluginGuard.kt | 27 ++++- 7 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a98cb2e0..66531e38 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -630,6 +630,12 @@ User Playlist + + Sign in to continue + This part of Spotube needs you to be signed in with your music provider. Sign in once and everything will show up here. + %1$s needs you to sign in before it can show this. Sign in once and everything will show up here. + Sign in + We\'re Truly Sorry Something isn\'t working right now. Your experience matters to us, and we\'re doing our best to fix this. Please try again or check the error details below. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index e5bcc686..b31bc300 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -54,6 +54,7 @@ import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.home.HomeScreenRepository import dev.krtirtho.spotube.modules.home.HomeScreenViewModel +import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel @@ -116,6 +117,7 @@ val sharedModules = module { // Home singleOf(::HomeScreenRepository) viewModelOf(::HomeScreenViewModel) + viewModelOf(::MetadataAuthViewModel) // Search singleOf(::SearchRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt index 9a7dc563..2b35c5b6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt @@ -50,6 +50,9 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel +import org.koin.compose.viewmodel.koinViewModel import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -77,7 +80,16 @@ import spotube.composeapp.generated.resources.* fun ErrorDisplay( errorMessage: String, onRetry: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier, + authViewModel: MetadataAuthViewModel = koinViewModel()) { + val authState by authViewModel.uiState.collectAsStateWithLifecycle() + if (authState.loginRequired) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + modifier = modifier) + return + } var showFullLog by remember { mutableStateOf(false) } val clipboardManager = LocalClipboardManager.current @@ -242,16 +254,6 @@ private fun ErrorLogDialog( } } -@Preview -@Composable -private fun ErrorDisplayPreview() { - Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { - ErrorDisplay( - errorMessage = "This is an error message", - onRetry = {}) - } -} - @Preview @Composable private fun ErrorLogDialogPreview() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt new file mode 100644 index 00000000..59ab53e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt @@ -0,0 +1,101 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.Lock +import compose.icons.feathericons.LogIn +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.* + +/** Friendly "sign in first" state for screens whose metadata plugin needs an account. */ +@Composable +fun LoginRequiredDisplay( + pluginName: String?, + onLogin: () -> Unit, + modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Box( + modifier = Modifier + .size(88.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = FeatherIcons.Lock, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = AppTheme.colors.primary) + } + + Text( + text = stringResource(Res.string.login_required_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = if (pluginName.isNullOrBlank()) { + stringResource(Res.string.login_required_description) + } else { + stringResource(Res.string.login_required_description_plugin, pluginName) + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + + MillenniumButton( + onClick = onLogin, + modifier = Modifier.height(44.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.LogIn, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text(stringResource(Res.string.login_required_action)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index ae863f1f..f3b75d96 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -117,7 +117,7 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { } Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { when (state.currentTab) { - LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false) { + LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryPlaylistsScreen( showCreatePlaylist = state.showCreatePlaylist, onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, @@ -125,10 +125,10 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { ) } - LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false) { + LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryAlbumsScreen(viewMode = viewMode) } - LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false) { + LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryArtistsScreen(viewMode = viewMode) } LibraryTab.LocalTracks -> LibraryLocalTracksScreen() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt new file mode 100644 index 00000000..1d7db0c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt @@ -0,0 +1,80 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.plugin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.di.injectLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import org.koin.core.component.KoinComponent + +data class MetadataAuthUiState( + /** True when the selected metadata plugin needs an account and the user isn't signed in. */ + val loginRequired: Boolean = false, + val pluginName: String? = null) + +/** + * Tells screens whether the selected metadata plugin is waiting for a login, so they can show a + * friendly sign-in prompt instead of surfacing the plugin's raw authorization exception. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MetadataAuthViewModel( + private val pluginManager: PluginManager) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + val uiState: StateFlow = combine( + pluginManager.selectedMetadataPlugin.flatMapLatest { service -> + if (service == null) flowOf(false) else loginRequiredFlow(service) + }, + pluginManager.state) { loginRequired, managerState -> + val name = (managerState as? PluginManagerStates.Data) + ?.selectedPlugins?.get(PluginAbility.METADATA)?.name + MetadataAuthUiState(loginRequired = loginRequired, pluginName = name) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) + + private fun loginRequiredFlow(service: dev.krtirtho.spotube.core.zipline.PluginService): Flow = flow { + val requiresAuth = try { + service.use { coreAPI.requiresAuthentication } + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } + if (!requiresAuth) { + emit(false) + } else { + service.loggedInFlow.collect { emit(!it) } + } + } + + fun login() { + val service = pluginManager.selectedMetadataPlugin.value ?: return + pluginManager.launchTask { + runCatching { service.use { coreAPI.login() } } + .onFailure { logger.e(it) { "Metadata plugin login failed" } } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt index d5ffc44d..3c501b4a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -43,6 +43,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.component.LoginRequiredDisplay +import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -76,19 +78,42 @@ import spotube.composeapp.generated.resources.no_metadata_plugin_title * the screen sit on an endless loading skeleton. * * @param backButton whether the placeholder's top bar shows a back button (for pushed screens). + * @param requireLogin also swap [content] for a friendly sign-in prompt while the plugin needs an + * account and the user isn't signed in (for screens that only make sense when logged in). * @param showTopBar false when the host screen already draws its own app bar (e.g. Library tabs). */ @Composable fun MetadataPluginGuard( backButton: Boolean = false, showTopBar: Boolean = true, + requireLogin: Boolean = false, viewModel: MetadataPluginGuardViewModel = koinViewModel(), + authViewModel: MetadataAuthViewModel = koinViewModel(), content: @Composable () -> Unit ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val authState by authViewModel.uiState.collectAsStateWithLifecycle() when (val s = state) { - is MetadataPluginGuardState.Available -> content() + is MetadataPluginGuardState.Available -> + if (requireLogin && authState.loginRequired) { + Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + if (showTopBar) ApplicationMainBar(backButton = backButton) + Box( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = LocalAppShellBottomInset.current), + contentAlignment = Alignment.Center) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + modifier = Modifier.widthIn(max = 420.dp)) + } + } + } else { + content() + } is MetadataPluginGuardState.Loading -> Box( Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) is MetadataPluginGuardState.Missing -> NoMetadataPluginScreen( From ba62ff03c3bb24b436b3e6312f5c8f8aed438b02 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 13:47:08 +0600 Subject: [PATCH 057/132] feat(user-agents): implement dynamic user agent fetching and caching mechanism --- .../core/tools/user_agents/UserAgents.kt | 193 ++++++++++++++++-- 1 file changed, 180 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt index a537a8a1..62700a4d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt @@ -17,18 +17,185 @@ package dev.krtirtho.spotube.core.tools.user_agents -object UserAgents { - private val agents = listOf( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 15; ALI-NX1 Build/HONORALI-N21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.92 Mobile Safari/537.36", - "Mozilla/5.0 (Linux; Android 14; SM-A528B Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.108 Mobile Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.7; rv:68.0) Gecko/20100101 Firefox/68.0 Waterfox/56.6.2021.2816", - "Mozilla/5.0 (Macintosh; Intel Mac OS X10.14; rv:65.0) Gecko/20100101 Firefox/65.0 Waterfox/56.2.7", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Vivaldi/7.6.3797.63", - "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.91 Safari/537.36 Vivaldi/1.92.917.39", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.5.3735.47" - ) +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.PlatformType +import dev.krtirtho.spotube.core.paths.Paths +import dev.krtirtho.spotube.getPlatform +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM +import kotlin.time.Clock - fun random(): String = agents.random() +/** + * Provides browser User-Agent strings that match the platform the app is running on + * (Android UAs only on Android, iOS only on iOS, and desktop UAs only on the matching + * Windows / macOS / Linux host). + * + * The primary source is the `top-user-agents` list maintained by Microlink (the 100 most + * used UAs, kept current), cached on disk for [CACHE_TTL_MS]. The hardcoded [FALLBACKS] + * are used whenever nothing has been fetched yet or the fetch fails. + * + * [random] is intentionally synchronous and never blocks on the network: it serves + * whatever is cached (even stale) and refreshes in the background when needed. + */ +object UserAgents { + private const val SOURCE_URL = + "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" + private const val CACHE_FILE_NAME = "user_agents_cache.json" + private const val CACHE_TTL_MS = 24L * 60 * 60 * 1000 // 1 day + + private val logger = Logger.withTag("UserAgents") + private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshMutex = Mutex() + + private val platformType: PlatformType = getPlatform().type + + @Serializable + private data class CacheEntry(val fetchedAtMs: Long, val agents: List) + private var remoteAgents: List = emptyList() + private var fetchedAtMs: Long = 0 + + // Loads the disk cache once and kicks off a background refresh if it is missing/stale. + private val bootstrap by lazy { + loadFromDisk() + if (isStale()) scope.launch { refresh() } + } + + fun random(): String { + bootstrap + return pool().random() + } + + /** + * Fetches the latest list from the network and caches it. No-op while the cache is + * still fresh unless [force] is set. Failures keep whatever data is already cached. + */ + suspend fun refresh(force: Boolean = false) = refreshMutex.withLock { + if (!force && !isStale()) return@withLock + try { + val agents = fetchRemote().filter(::isValidAgent) + if (agents.isEmpty()) return@withLock + remoteAgents = agents + fetchedAtMs = now() + saveToDisk(CacheEntry(fetchedAtMs, agents)) + } catch (e: Exception) { + logger.w(e) { "Failed to refresh user agents, using cached/fallback ones" } + } + } + + private fun pool(): List { + val matching = remoteAgents.filter { matchesPlatform(it, platformType) } + return matching.ifEmpty { FALLBACKS.getValue(platformType) } + } + + private fun isStale() = now() - fetchedAtMs >= CACHE_TTL_MS + + private fun now() = Clock.System.now().toEpochMilliseconds() + + private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/") + + private suspend fun fetchRemote(): List { + val client = HttpClient(CIO) + try { + val res = client.get(SOURCE_URL) + if (!res.status.isSuccess()) error("Unexpected status ${res.status}") + return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) + } finally { + client.close() + } + } + + private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() + + private fun loadFromDisk() { + try { + val path = cachePath() + val fs = FileSystem.SYSTEM + if (!fs.exists(path)) return + val entry = json.decodeFromString(fs.read(path) { readUtf8() }) + val agents = entry.agents.filter(::isValidAgent) + if (agents.isEmpty()) return + remoteAgents = agents + fetchedAtMs = entry.fetchedAtMs + } catch (e: Exception) { + logger.w(e) { "Failed to read user agents cache" } + } + } + + private fun saveToDisk(entry: CacheEntry) { + try { + val path = cachePath() + val tmp = "$path.tmp".toPath() + val fs = FileSystem.SYSTEM + path.parent?.let { fs.createDirectories(it) } + fs.write(tmp) { writeUtf8(json.encodeToString(CacheEntry.serializer(), entry)) } + fs.atomicMove(tmp, path) + } catch (e: Exception) { + logger.w(e) { "Failed to write user agents cache" } + } + } + + internal fun matchesPlatform(ua: String, type: PlatformType): Boolean { + val isIos = "iPhone" in ua || "iPad" in ua || "iPod" in ua + val isAndroid = "Android" in ua + return when (type) { + PlatformType.IOS -> isIos + PlatformType.Android -> isAndroid + PlatformType.Windows -> "Windows NT" in ua && !isAndroid + PlatformType.MacOS -> "Macintosh" in ua && !isIos + PlatformType.Linux -> ("X11" in ua || "Linux" in ua) && !isAndroid && "CrOS" !in ua + // Unknown host: any desktop UA is a safe choice. + PlatformType.Unknown -> !isIos && !isAndroid + } + } + + private val FALLBACKS: Map> = mapOf( + PlatformType.Windows to listOf( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Vivaldi/7.6.3797.63", + ), + PlatformType.MacOS to listOf( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.5.3735.47", + ), + PlatformType.Linux to listOf( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + ), + PlatformType.Android to listOf( + "Mozilla/5.0 (Linux; Android 15; ALI-NX1 Build/HONORALI-N21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.92 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; SM-A528B Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.108 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36", + ), + PlatformType.IOS to listOf( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/146.0.0.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + ), + PlatformType.Unknown to listOf( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + ), + ) } From 61dd89f3edaf71b8072c9df9384c856987fec4a9 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 14:26:39 +0600 Subject: [PATCH 058/132] fix(stream-proxy): stream upstream audio instead of buffering the whole track Ktor's request() buffers the full body before returning, so ExoPlayer timed out waiting for headers. Use prepareRequest().execute{} and tee the body to the cache file, committing only complete 200 responses. Treat player disconnects (broken pipe) as debug-level noise rather than upstream errors. --- .../spotube/core/server/CacheManager.kt | 4 +- .../spotube/core/server/StreamProxy.kt | 151 ++++++++++++------ 2 files changed, 104 insertions(+), 51 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt index b2c73d19..01423b5f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt @@ -157,12 +157,12 @@ class CacheManager( suspend fun commitCacheEntry( trackId: String, filename: String, - allBytes: ByteArray, + sizeBytes: Long, contentType: String) { val entry = CacheEntry( trackId = trackId, filename = filename, - sizeBytes = allBytes.size.toLong(), + sizeBytes = sizeBytes, createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), contentType = contentType) cacheMutex.withLock { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt index 02e3f6c5..607091ee 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt @@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import io.ktor.client.HttpClient +import io.ktor.client.request.prepareRequest import io.ktor.client.request.request import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel @@ -38,16 +39,19 @@ import io.ktor.http.decodeURLQueryComponent import io.ktor.http.encodeURLParameter import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respond +import io.ktor.util.cio.ChannelWriteException +import io.ktor.utils.io.ClosedWriteChannelException import io.ktor.utils.io.copyTo -import io.ktor.utils.io.readRemaining +import io.ktor.utils.io.readAvailable import io.ktor.utils.io.writeFully +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.io.readByteArray import okio.FileSystem +import okio.IOException import okio.Path.Companion.toPath import okio.SYSTEM import okio.buffer @@ -260,18 +264,25 @@ internal class StreamProxy( ) { logger.d { "Proxying segment for track $trackId" } - val response = runCatching { - httpClient.request(segmentUrl) { + runCatching { + httpClient.prepareRequest(segmentUrl) { method = HttpMethod.Get call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + }.execute { response -> respondWithSegment(call, response, trackId) } + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + if (throwable.isClientDisconnect()) { + logger.d { "Player closed the connection while streaming segment of track $trackId" } + return } - }.getOrElse { throwable -> logger.w(throwable) { "Failed to fetch segment for track $trackId" } - call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") - return + runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") } } + } + + private suspend fun respondWithSegment(call: ApplicationCall, response: HttpResponse, trackId: String) { if (response.status.value >= 400) { logger.w { "Segment request failed for track $trackId with status ${response.status.value}" } @@ -305,48 +316,65 @@ internal class StreamProxy( var currentUrl = streamUrl var attemptedRefresh = false - var upstream: HttpResponse? + // `request()` buffers the whole body before returning, which stalls the player until + // the full track is downloaded. `execute {}` hands over the response as soon as headers + // arrive so the body can be streamed. while (true) { - val response = runCatching { - httpClient.request(currentUrl) { + var retryUrl: String? = null + val failure = runCatching { + httpClient.prepareRequest(currentUrl) { method = requestMethod call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + }.execute { response -> + if (response.status.value < 400) { + respondWithUpstream(call, response, trackId, requestMethod) + return@execute + } + + logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" } + streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl) + + if (!attemptedRefresh) { + val refreshedUrl = + streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url + if (!refreshedUrl.isNullOrBlank() && refreshedUrl != currentUrl) { + retryUrl = refreshedUrl + return@execute + } + } + respondWithUpstream(call, response, trackId, requestMethod) } - }.getOrElse { throwable -> - logger.w(throwable) { "Failed to fetch upstream stream for track $trackId" } - call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream") + }.exceptionOrNull() + + if (failure != null) { + if (failure is CancellationException) throw failure + if (failure.isClientDisconnect()) { + logger.d { "Player closed the connection while streaming track $trackId" } + return + } + logger.w(failure) { "Failed to fetch upstream stream for track $trackId" } + // Throws if the response already started streaming; nothing more to send then + runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream") } return } - if (response.status.value < 400) { - upstream = response - break - } - - logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" } - streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl) - - if (attemptedRefresh) { - upstream = response - break - } - - val refreshedUrl = - streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url - if (refreshedUrl.isNullOrBlank() || refreshedUrl == currentUrl) { - upstream = response - break - } - + val next = retryUrl ?: return logger.d { "Retrying stream for track $trackId using refreshed URL" } - currentUrl = refreshedUrl + currentUrl = next attemptedRefresh = true } + } + private suspend fun respondWithUpstream( + call: ApplicationCall, + upstream: HttpResponse, + trackId: String, + requestMethod: HttpMethod + ) { if (upstream.status.value >= 400 && isCachingEnabled()) { val cachedEntry = cacheManager.findCachedEntry(trackId) if (cachedEntry != null) { @@ -380,7 +408,9 @@ internal class StreamProxy( return } - val doCache = requestMethod == HttpMethod.Get && isCachingEnabled() + // Only a full 200 response can be cached; 206 partial responses must not be stored as the whole track + val doCache = requestMethod == HttpMethod.Get && isCachingEnabled() && + upstream.status == HttpStatusCode.OK val cacheTrack = if (doCache) getTrackMetadata(trackId) else null val cacheFilename = if (cacheTrack != null) { cacheManager.resolveCacheFilename(cacheTrack, downstreamHeaders[HttpHeaders.ContentType]) @@ -388,6 +418,7 @@ internal class StreamProxy( null } val trackIdForCache = trackId + val expectedLength = downstreamContentLength val cacheContentType = downstreamHeaders[HttpHeaders.ContentType] ?: "application/octet-stream" call.respond(object : OutgoingContent.WriteChannelContent() { @@ -402,26 +433,38 @@ internal class StreamProxy( val cacheDir = cacheManager.resolveCacheDir() val cacheFile = cacheDir / cacheFilename.toPath() fileSystem.createDirectories(cacheDir) + var completed = false + var totalBytes = 0L try { - val packet = upstream.bodyAsChannel().readRemaining() - val allBytes = packet.readByteArray() - channel.writeFully(allBytes) + val source = upstream.bodyAsChannel() + val buffer = ByteArray(64 * 1024) fileSystem.sink(cacheFile).buffer().use { sink -> - sink.write(allBytes) + while (true) { + val read = source.readAvailable(buffer, 0, buffer.size) + if (read < 0) break + if (read == 0) continue + channel.writeFully(buffer, 0, read) + channel.flush() + sink.write(buffer, 0, read) + totalBytes += read + } } - cacheManager.commitCacheEntry( - trackId = trackIdForCache, - filename = cacheFilename, - allBytes = allBytes, - contentType = cacheContentType) - scope.launch { - cacheManager.evictIfNeeded() + completed = expectedLength == null || expectedLength == totalBytes + if (completed) { + cacheManager.commitCacheEntry( + trackId = trackIdForCache, + filename = cacheFilename, + sizeBytes = totalBytes, + contentType = cacheContentType) + scope.launch { + cacheManager.evictIfNeeded() + } } - } catch (e: Exception) { - if (fileSystem.exists(cacheFile)) { + } finally { + // Player disconnected or stream was incomplete: drop the partial file + if (!completed && fileSystem.exists(cacheFile)) { fileSystem.delete(cacheFile) } - throw e } } else { upstream.bodyAsChannel().copyTo(channel) @@ -487,6 +530,16 @@ internal class StreamProxy( return rewritten } + /** + * True when the failure came from writing to the player's socket (seek, skip or stop closes + * it mid-stream), as opposed to a failure fetching from upstream. + */ + private fun Throwable.isClientDisconnect(): Boolean = + generateSequence(this) { it.cause }.take(10).any { + it is ChannelWriteException || it is ClosedWriteChannelException || + (it is IOException && it.message?.contains("Broken pipe", ignoreCase = true) == true) + } + private fun encodeUrl(url: String): String = url.encodeURLParameter() private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent() From 09d77211b9f7b9687410a0ae11dc2dfd85ddb548 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 14:26:39 +0600 Subject: [PATCH 059/132] fix(newpipe): fall back through multiple searches when ISRC search misses NewPipe durations are seconds, not ms, so duration scoring never matched. Fix the uploader match direction, normalize titles, and walk a ladder of searches (ISRC, songs, videos, title only) until a confident match is found. Return nothing rather than unrelated results. Add detailed ISRC/NewPipe logs. --- .../core/newpipe/NewPipeService.androidJvm.kt | 15 +- .../spotube/core/newpipe/NewPipeService.kt | 10 +- .../plugin_apis/newpipe_yt/ISRCProviders.kt | 92 ++++++++++-- .../newpipe_yt/RealNewPipeAudioAPI.kt | 131 ++++++++++++++---- .../core/newpipe/NewPipeService.ios.kt | 15 +- 5 files changed, 215 insertions(+), 48 deletions(-) diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt index 5231f3b4..c476f876 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -26,12 +26,19 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.stream.StreamInfoItem +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 + actual class NewPipeService { - actual suspend fun searchVideos(query: String): List { + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( query, - mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), "" ) extractor.fetchPage() @@ -45,7 +52,7 @@ actual class NewPipeService { title = item.name, url = item.url, uploader = item.uploaderName, - durationMs = item.duration, + durationMs = secondsToMs(item.duration), thumbnailUrl = item.thumbnails.first().url) } else { null @@ -101,7 +108,7 @@ actual class NewPipeService { title = extractor.name, url = extractor.url, uploader = extractor.uploaderName, - durationMs = extractor.length, + durationMs = secondsToMs(extractor.length), thumbnailUrl = extractor.thumbnails.first().url, // fallback to video streams if no audio streams are available audioStreams = audioStreams.ifEmpty { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt index 5cbb3c81..880a587f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt @@ -17,7 +17,15 @@ package dev.krtirtho.spotube.core.newpipe +enum class SearchKind { + /** YouTube Music "Songs" catalogue. */ + Songs, + + /** Regular YouTube videos; covers tracks that are not in the Music songs catalogue. */ + Videos +} + expect class NewPipeService() { - suspend fun searchVideos(query: String): List + suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List suspend fun getVideoInfo(id: String): VideoInfo } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index e36e3ad7..d10aa317 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -21,6 +21,10 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import dev.krtirtho.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.parameter @@ -37,7 +41,10 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -class ISRCProviders { +class ISRCProviders : KoinComponent { + private val logger by injectLogger() + private val lenientJson = Json { isLenient = true; ignoreUnknownKeys = true } + companion object { val greedyGreenCorp = listOf(-0x03, -0x01, 0x05, -0x0B, -0x03, 0x13) @@ -57,6 +64,17 @@ class ISRCProviders { } } + private fun MetadataTrack.describe() = + "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + + "durationMs=$durationMs isrc=$isrcCode externalUri=$externalUri" + + /** Logs the full raw body (so parse failures are diagnosable) then decodes it. */ + private suspend inline fun HttpResponse.decodeLogged(provider: String): T { + val text = bodyAsText() + logger.i { "[ISRC:$provider] status=${status.value} rawBody=$text" } + return lenientJson.decodeFromString(text) + } + private var ifpiSessionToken: String? = null private val ifpiDefaultHeaders = mapOf( "Content-Type" to "application/json", @@ -78,20 +96,23 @@ class ISRCProviders { private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { if (ifpiSessionToken != null) return@withContext ifpiSessionToken + logger.i { "[ISRC:ifpi] requesting session token" } val res = client.get("https://isrc-api.soundexchange.com/api/ext/login") { headers.appendAll(ifpiDefaultHeaders) } if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] session login failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("ifpi-login") ifpiSessionToken = body.token body.token } suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:ifpi] input: ${track.describe()}" } val sessonToken = ifpiSessionToken() ?: return@withContext null val input = IFPIRecordingRequestInput( searchFields = IFPIRecordingRequestInput.SearchFields( @@ -113,6 +134,7 @@ class ISRCProviders { ) ) + logger.i { "[ISRC:ifpi] request searchFields=${input.searchFields}" } val res = client.post("https://isrc-api.soundexchange.com/api/ext/recordings") { contentType(ContentType.Application.Json) headers { @@ -123,17 +145,28 @@ class ISRCProviders { } if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] search failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("ifpi") if (body.recordings.isEmpty()) { + logger.i { "[ISRC:ifpi] no recordings returned" } return@withContext null } + body.recordings.forEachIndexed { i, r -> + logger.i { + "[ISRC:ifpi] candidate #$i isrc=${r.isrc} title='${r.recordingTitle}' " + + "artist='${r.recordingArtistName}' duration=${r.duration} year=${r.recordingYear} " + + "version=${r.recordingVersion} valid=${r.isValidIsrc}" + } + } + logger.i { "[ISRC:ifpi] picking FIRST candidate isrc=${body.recordings.first().isrc} (no artist/title validation)" } body.recordings.first().isrc } suspend fun musicGateway(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:musicGateway] input: ${track.describe()}" } val res = client.get("https://www.musicgateway.com/isrc-finder") { parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}") parameter("type", "name") @@ -141,39 +174,47 @@ class ISRCProviders { } if (res.status != HttpStatusCode.OK) { + logger.w { "[ISRC:musicGateway] failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("musicGateway") val results = body.result.tracks.items if (results.isEmpty()) { + logger.i { "[ISRC:musicGateway] no results" } return@withContext null } var isrcCode: String? = null - for (item in results) { - val isExactTitle = item.name.equals(track.title, ignoreCase = true) - val isArtistMatch = item.artists.any { + for ((_, artists, externalIds, id, name) in results) { + val isExactTitle = name.equals(track.title, ignoreCase = true) + val isArtistMatch = artists.any { it.name.equals( track.artists.firstOrNull()?.name ?: "", ignoreCase = true ) } - val isSameId = item.id == track.id + val isSameId = id == track.id + logger.i { + "[ISRC:musicGateway] candidate id=$id name='$name' artists=${artists.map { it.name }} " + + "isrc=${externalIds.isrc} exactTitle=$isExactTitle artistMatch=$isArtistMatch sameId=$isSameId" + } if (isSameId || (isExactTitle && isArtistMatch)) { - isrcCode = item.externalIds.isrc + isrcCode = externalIds.isrc break } } + logger.i { "[ISRC:musicGateway] result isrc=$isrcCode" } isrcCode } suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) { val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false val uri = "https://open.$greedyGreenCorp.com/track/${track.id}" + logger.i { "[ISRC:soundplate] input: ${track.describe()} useUri=$isGreedyGreenCorp" } val res = client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") { if (isGreedyGreenCorp) { @@ -185,16 +226,39 @@ class ISRCProviders { } if (res.status.value != 200) { + logger.w { "[ISRC:soundplate] failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("soundplate") + logger.i { + "[ISRC:soundplate] returned isrc=${body.isrc} name='${body.name}' artist='${body.artist}' " + + "album='${body.album}' year=${body.year} (input title='${track.title}' " + + "artist='${track.artists.firstOrNull()?.name}') — not validated against input" + } body.isrc } suspend fun auto(track: MetadataTrack): String? { - return runCatching { soundplate(track) }.getOrNull() - ?: runCatching { ifpi(track) }.getOrNull() - ?: runCatching { musicGateway(track) }.getOrNull() + logger.i { "[ISRC:auto] start: ${track.describe()}" } + val providers = listOf String?>>( + "soundplate" to ::soundplate, + "ifpi" to ::ifpi, + "musicGateway" to ::musicGateway) + for ((name, provider) in providers) { + val result = runCatching { provider(track) } + result.exceptionOrNull()?.let { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[ISRC:auto] provider $name threw" } + } + val isrc = result.getOrNull() + logger.i { "[ISRC:auto] provider $name -> $isrc" } + if (isrc != null) { + logger.i { "[ISRC:auto] using isrc=$isrc from $name" } + return isrc + } + } + logger.w { "[ISRC:auto] no provider produced an ISRC" } + return null } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index 92b84513..9cd248bb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -25,6 +25,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.newpipe.NewPipeService +import dev.krtirtho.spotube.core.newpipe.SearchKind import dev.krtirtho.spotube.core.newpipe.VideoSearchResult import org.koin.core.component.KoinComponent @@ -34,27 +35,61 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { companion object { private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$") + private val isrcRegex = Regex("^[A-Z]{2}[A-Z0-9]{3}\\d{7}$") + private const val minConfidence = 0.3f + private const val confidentMatch = 0.6f } private val isrcProvider = ISRCProviders() + private fun normalize(value: String): String = + value.lowercase() + .replace(Regex("[^\\p{L}\\p{N}]+"), " ") + .trim() + + /** Drops "(feat. X)", "[Remastered]" style suffixes so the core title can be matched. */ + private fun coreTitle(title: String): String = + normalize(title.replace(Regex("[(\\[].*?[)\\]]"), " ")) + private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { var confidence = 0f - val isTitleMatch = track.title.lowercase() in video.title.lowercase() - val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() } - val isAlbumInTitle = - track.album != null && track.album!!.title.lowercase() in video.title.lowercase() - val isUploaderMatch = - video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "") - val isDurationMatch = - video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds + val videoTitle = normalize(video.title) + val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) + val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) + val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() } + val title = coreTitle(track.title) + + val videoTokens = videoTitle.split(' ').toSet() + val isTitleMatch = title.isNotEmpty() && + (title in videoTitle || title.split(' ').all { it in videoTokens }) + val isArtistInTitle = artistNames.any { it in videoTitle } + val isAlbumInTitle = track.album?.title?.let { normalize(it) } + ?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false + val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it } + // Unknown duration (0) must neither reward nor punish + val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE if (isTitleMatch) confidence += 0.3f if (isUploaderMatch) confidence += 0.3f - if (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds + if (durationDiff <= 10_000) confidence += 0.2f + else if (durationDiff <= 30_000) confidence += 0.1f if (isArtistInTitle) confidence += 0.1f if (isAlbumInTitle) confidence += 0.1f + // Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos + if (isTopicChannel && isUploaderMatch) confidence += 0.1f + confidence = confidence.coerceAtMost(1f) + + logger.i { + "[Match] video id=${video.id} title='${video.title}' uploader='${video.uploader}' " + + "durationMs=${video.durationMs} url=${video.url}\n" + + " vs track title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs}\n" + + " normalized: videoTitle='$videoTitle' uploader='$uploader' coreTitle='$title' artists=$artistNames\n" + + " titleMatch=$isTitleMatch uploaderMatch=$isUploaderMatch topic=$isTopicChannel " + + "artistInTitle=$isArtistInTitle albumInTitle=$isAlbumInTitle durationDiffMs=$durationDiff " + + "=> confidence=$confidence" + } return AudioSource.Basic( id = video.id, @@ -125,25 +160,71 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { logger.i { "No YouTube ID or URL found, performing search with track metadata" } - val isrcCode = track.isrcCode ?: isrcProvider.auto(track) - val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim() - - logger.i { "Searching for video with query: $searchQuery" } - val videos = newPipeService.searchVideos(isrcCode ?: searchQuery) - - logger.i { "Found ${videos.size} videos with query: $searchQuery" } - var sources = videos.map { transformVideo(track, it) } - - if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) { - logger.i { "ISRC search failed, fallback to title search" } - val videos = newPipeService.searchVideos(searchQuery) - sources = videos.map { transformVideo(track, it) } + logger.i { + "[Search] input track id=${track.id} title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs} trackIsrc=${track.isrcCode} " + + "externalUri=${track.externalUri}" + } + val rawIsrc = track.isrcCode ?: isrcProvider.auto(track) + val isrcCode = rawIsrc + ?.trim()?.uppercase()?.replace("-", "") + ?.takeIf { isrcRegex.matches(it) } + logger.i { + "[Search] isrc raw=$rawIsrc (from ${if (track.isrcCode != null) "track" else "providers"}) " + + "validated=$isrcCode" } - logger.i { "Found ${sources.size} sources with confidence >= 0.3f" } + val artistText = track.artists.joinToString(" ") { it.name }.trim() + val firstArtist = track.artists.firstOrNull()?.name?.trim().orEmpty() + val plainTitle = track.title.replace(Regex("[(\\[].*?[)\\]]"), " ").replace(Regex("\\s+"), " ").trim() - // Return 5 highest to lowest confidence - return sources.sortedByDescending { it.confidence }.take(5) + // Ordered from most to least trusted. The ISRC is only a hint: a text search for it on + // YouTube Music often returns unrelated songs, so every step is scored against the track + // and the next step runs until one result clears [confidentMatch]. + val plan = buildList { + if (isrcCode != null) add("isrc" to (isrcCode to SearchKind.Songs)) + add("songs: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Songs)) + if (plainTitle.isNotEmpty() && firstArtist.isNotEmpty() && plainTitle != track.title) { + add("songs: plain title+first artist" to ("$plainTitle $firstArtist" to SearchKind.Songs)) + } + add("videos: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Videos)) + add("songs: title only" to (plainTitle.ifEmpty { track.title } to SearchKind.Songs)) + } + + val sources = linkedMapOf() + val searched = mutableSetOf>() + for ((stage, request) in plan) { + if (sources.values.any { it.confidence >= confidentMatch }) break + if (!searched.add(request)) continue + val (query, kind) = request + val raw = runCatching { newPipeService.searchVideos(query, kind) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[Search] stage '$stage' failed for query '$query' ($kind)" } + }.getOrDefault(emptyList()) + logger.i { "[Search] stage '$stage' query='$query' kind=$kind -> ${raw.size} results: $raw" } + val scored = raw.map { transformVideo(track, it) } + logger.i { + "[Search] stage '$stage' best confidence=${scored.maxOfOrNull { it.confidence }} " + + "(need >= $confidentMatch to stop)" + } + scored.forEach { s -> + val existing = sources[s.id] + if (existing == null || existing.confidence < s.confidence) sources[s.id] = s + } + } + + val ranked = sources.values.sortedByDescending { it.confidence } + val matched = ranked.filter { it.confidence >= minConfidence } + logger.i { "[Search] ${matched.size} of ${ranked.size} candidates have confidence >= $minConfidence" } + logger.i { + "[Search] final ranking:\n" + matched.take(5).mapIndexed { i, s -> + " #$i ${s.id} conf=${s.confidence} '${s.title}' by '${s.artist}'" + }.joinToString("\n") + } + + // Never play a result that does not match the track at all + return matched.take(5) } override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt index 16820c4f..26a76fab 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt @@ -26,12 +26,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 + actual class NewPipeService { - actual suspend fun searchVideos(query: String): List { + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( query, - mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), "" ) extractor.fetchPage() @@ -43,7 +50,7 @@ actual class NewPipeService { title = item.name, url = item.url, uploader = item.uploaderName ?: "Unknown", - durationMs = item.duration, + durationMs = secondsToMs(item.duration), thumbnailUrl = item.thumbnails.first().url, id = Url(item.url).parameters["v"] ?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}") @@ -63,7 +70,7 @@ actual class NewPipeService { title = extractor.name ?: "Unknown", url = extractor.url ?: "https://www.youtube.com/watch?v=$id", uploader = extractor.uploaderName, - durationMs = extractor.length, + durationMs = secondsToMs(extractor.length), thumbnailUrl = extractor.thumbnails.first().url, audioStreams = extractor.audioStreams() .filter { stream -> stream.isUrl() && stream.format != null } From da394d40eae21867fa9c1d8aab1473aa49732b74 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 14:29:43 +0600 Subject: [PATCH 060/132] fix(audio-api): enhance fallback mechanism for text search results in YouTube --- .../newpipe_yt/RealNewPipeAudioAPI.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index 9cd248bb..788255b3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -193,6 +193,8 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { val sources = linkedMapOf() val searched = mutableSetOf>() + // Results of the text searches in YouTube's relevance order, used if nothing scores well + val textSearchResults = linkedMapOf() for ((stage, request) in plan) { if (sources.values.any { it.confidence >= confidentMatch }) break if (!searched.add(request)) continue @@ -208,6 +210,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { "[Search] stage '$stage' best confidence=${scored.maxOfOrNull { it.confidence }} " + "(need >= $confidentMatch to stop)" } + if (stage != "isrc") scored.forEach { if (it.id !in textSearchResults) textSearchResults[it.id] = it } scored.forEach { s -> val existing = sources[s.id] if (existing == null || existing.confidence < s.confidence) sources[s.id] = s @@ -223,8 +226,19 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { }.joinToString("\n") } - // Never play a result that does not match the track at all - return matched.take(5) + if (matched.isNotEmpty()) return matched.take(5) + + // Nothing scored well. Playing YouTube's best text-search guess beats failing playback, + // and ISRC results are skipped because an ISRC text search tends to return unrelated songs. + val fallback = textSearchResults.values.toList() + .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order + .take(5) + .ifEmpty { ranked.take(5) } + logger.w { + "[Search] no confident match for '${track.title}'; falling back to best guesses: " + + fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } + } + return fallback } override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { From c9d16a25c6bfe8f7af3fea656d407bcbb0db4612 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 14:36:27 +0600 Subject: [PATCH 061/132] fix(audio-player): android shuffle wasnt working --- .../core/audioplayer/AudioPlayer.android.kt | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt index 63f7f49b..b3edf24e 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt @@ -84,6 +84,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private var disposed = false private val currentPlaylist = mutableListOf() + private val originalPlaylist = mutableListOf() + private var shuffleEnabled = false private val urlIndexMap = mutableMapOf() private val _playerState = MutableStateFlow(PlayerState.IDLE) @@ -143,10 +145,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface ) } - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - _shuffleMode.tryEmit(shuffleModeEnabled) - } - override fun onVolumeChanged(volume: Float) { _volume.tryEmit(volume) } @@ -260,8 +258,37 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun shuffle(enabled: Boolean) { withContext(Dispatchers.Main) { - exoPlayer.shuffleModeEnabled = enabled + if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return@withContext + + val currentIndex = exoPlayer.currentMediaItemIndex + val current = currentPlaylist.getOrNull(currentIndex) ?: return@withContext + + val rest = if (enabled) { + originalPlaylist.filterNot { it.url == current.url }.shuffled() + } else { + originalPlaylist.filterNot { it.url == current.url } + } + // Disabling restores original order around the current item. + val before = if (enabled) emptyList() else originalPlaylist.takeWhile { it.url != current.url } + val after = if (enabled) rest else originalPlaylist.dropWhile { it.url != current.url }.drop(1) + + // Reorder in place around the playing item so playback is not interrupted. + if (currentIndex < exoPlayer.mediaItemCount - 1) { + exoPlayer.removeMediaItems(currentIndex + 1, exoPlayer.mediaItemCount) + } + if (currentIndex > 0) exoPlayer.removeMediaItems(0, currentIndex) + exoPlayer.addMediaItems(0, before.map { it.toExoMediaItem() }) + exoPlayer.addMediaItems(after.map { it.toExoMediaItem() }) + + currentPlaylist.clear() + currentPlaylist.addAll(before + current + after) + urlIndexMap.clear() + currentPlaylist.forEachIndexed { i, item -> urlIndexMap[item.url] = i } + + shuffleEnabled = enabled _shuffleMode.tryEmit(enabled) + _playlist.tryEmit(currentPlaylist.toList()) + syncCurrentItem() } } @@ -273,6 +300,10 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface withContext(Dispatchers.Main) { currentPlaylist.clear() currentPlaylist.addAll(playlist) + originalPlaylist.clear() + originalPlaylist.addAll(playlist) + shuffleEnabled = false + _shuffleMode.tryEmit(false) urlIndexMap.clear() playlist.forEachIndexed { index, item -> urlIndexMap[item.url] = index @@ -305,6 +336,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun addMediaItem(mediaItem: MediaItem) { withContext(Dispatchers.Main) { currentPlaylist.add(mediaItem) + originalPlaylist.add(mediaItem) urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex _playlist.tryEmit(currentPlaylist.toList()) exoPlayer.addMediaItem(mediaItem.toExoMediaItem()) @@ -317,6 +349,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size) currentPlaylist.add(insertIndex, mediaItem) + val origCurrent = currentPlaylist.getOrNull(currentIndex)?.let { c -> originalPlaylist.indexOfFirst { it.url == c.url } } ?: -1 + originalPlaylist.add((origCurrent + 1).coerceIn(0, originalPlaylist.size), mediaItem) urlIndexMap.clear() currentPlaylist.forEachIndexed { index, item -> urlIndexMap[item.url] = index @@ -331,6 +365,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface withContext(Dispatchers.Main) { val index = urlIndexMap[mediaItem.url] ?: return@withContext currentPlaylist.removeAt(index) + originalPlaylist.removeAll { it.url == mediaItem.url } urlIndexMap.clear() currentPlaylist.forEachIndexed { i, item -> urlIndexMap[item.url] = i @@ -346,6 +381,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface val item = currentPlaylist.removeAt(fromIndex) currentPlaylist.add(toIndex, item) + if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) { + originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex)) + } urlIndexMap.clear() currentPlaylist.forEachIndexed { i, it -> urlIndexMap[it.url] = i @@ -398,6 +436,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface disposed = true exoPlayer.release() currentPlaylist.clear() + originalPlaylist.clear() urlIndexMap.clear() _playerState.tryEmit(PlayerState.IDLE) From 77a9cef3a1cf4aa5daf71e09687530d21da1988f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 14:44:39 +0600 Subject: [PATCH 062/132] fix(stream-proxy): improve logging for cache timeout and handle disconnection scenarios --- .../kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt | 5 ++++- .../dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt | 4 ---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt index 607091ee..cb52ae66 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt @@ -131,8 +131,11 @@ internal class StreamProxy( } return } + // The caching request ended without committing (player disconnected or the + // source was switched); nothing left to wait for. + if (streamingMutex.withLock { trackId !in streamingInProgress }) break } - logger.w { "Timeout waiting for cache of track $trackId, falling through to upstream" } + logger.d { "No cache for track $trackId after waiting, falling through to upstream" } } try { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index 24285b5b..22e74fbf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -56,11 +56,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import compose.icons.FeatherIcons -import compose.icons.feathericons.X import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import kotlinx.coroutines.launch @@ -129,7 +126,6 @@ fun LyricsScreen( .weight(1f) .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = LocalAppShellBottomInset.current + 24.dp) ) { Spacer(modifier = Modifier.height(16.dp)) when { From 7743d6d4c3ff1cc0835fe6462b492da55538d9f5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 15:12:10 +0600 Subject: [PATCH 063/132] feat(lyrics): implement SyncedLyricsList component and enhance LyricsViewModel for better state management --- .../spotube/modules/lyrics/LyricsScreen.kt | 457 +++++++++--------- .../spotube/modules/lyrics/LyricsViewModel.kt | 62 ++- .../modules/lyrics/SyncedLyricsList.kt | 196 ++++++++ .../modules/shell/AppExpandedPlayer.kt | 124 ++--- 4 files changed, 510 insertions(+), 329 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index 22e74fbf..fcec5cef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -22,37 +22,45 @@ import org.spotube.millennium.composables.MillenniumBarsLoader import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope 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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -60,7 +68,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res @@ -77,95 +84,87 @@ fun LyricsScreen( modifier: Modifier = Modifier, onClose: (() -> Unit)? = null) { val uiState by viewModel.uiState.collectAsState() - val positionMillis by viewModel.positionMillisFlow.collectAsState() - val scope = rememberCoroutineScope() + val colors = MillenniumTheme.colors - Column(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically) { - if (onClose != null) { - MillenniumIconButton( - onClick = onClose, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = stringResource(Res.string.common_close) - ) - } - } else { - ApplicationBackButton() - } - Spacer(Modifier.weight(1f)) + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to colors.accent.copy(alpha = 0.14f), + 0.55f to colors.background, + 1f to colors.background + ) + ) + ) { + val isWide = maxWidth >= 840.dp + + Column(Modifier.fillMaxSize()) { Row( modifier = Modifier - .clip(RoundedCornerShape(50.dp)) - .background(AppTheme.colors.surfaceVariant) - .padding(3.dp), - horizontalArrangement = Arrangement.spacedBy(3.dp) - ) { - LyricsModeTab( - label = stringResource(Res.string.lyrics_mode_synced), - isSelected = uiState.mode == LyricType.SYNCED, - onClick = { viewModel.setMode(LyricType.SYNCED) } - ) - LyricsModeTab( - label = stringResource(Res.string.lyrics_mode_plain), - isSelected = uiState.mode == LyricType.STATIC, - onClick = { viewModel.setMode(LyricType.STATIC) } - ) - } - Spacer(Modifier.weight(1f)) - Spacer(Modifier.width(44.dp)) - } - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(16.dp)) - when { - uiState.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - MillenniumBarsLoader(size = 48.dp) - } - } - - uiState.error != null -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = uiState.error ?: stringResource(Res.string.common_unknown_error), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.error, - textAlign = TextAlign.Center - ) - } - } - - uiState.mode == LyricType.SYNCED -> { - SyncedLyricsContent( - syncedLyrics = uiState.syncedLyrics ?: emptyList(), - currentIndex = viewModel.currentLyricIndex(positionMillis), - onScrollToIndex = { index -> - scope.launch { - // scroll will be handled by LazyColumn - } + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(48.dp), contentAlignment = Alignment.CenterStart) { + if (onClose != null) { + MillenniumIconButton( + onClick = onClose, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = stringResource(Res.string.common_close) + ) } - ) + } else { + ApplicationBackButton() + } } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + MillenniumSegmentedButtonRow(Modifier.widthIn(max = 280.dp)) { + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.SYNCED, + onClick = { viewModel.setMode(LyricType.SYNCED) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_synced)) } + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.STATIC, + onClick = { viewModel.setMode(LyricType.STATIC) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_plain)) } + } + } + Spacer(Modifier.width(48.dp)) + } - uiState.mode == LyricType.STATIC -> { - PlainLyricsContent(plainLyrics = uiState.plainLyrics ?: "") + if (isWide) { + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 40.dp), + horizontalArrangement = Arrangement.spacedBy(40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TrackPane(uiState, Modifier.weight(0.38f)) + Column(Modifier.weight(0.62f).fillMaxHeight()) { + LyricsBody(uiState, viewModel) + } + } + } else { + CompactTrackHeader( + uiState, + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp) + ) + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + LyricsBody(uiState, viewModel) } } } @@ -173,151 +172,175 @@ fun LyricsScreen( } @Composable -private fun LyricsModeTab( - label: String, - isSelected: Boolean, - onClick: () -> Unit) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(50.dp)) - .background( - if (isSelected) { - AppTheme.colors.primary - } else { - Color.Transparent - } +private fun ColumnScope.LyricsBody(uiState: LyricsUiState, viewModel: LyricsViewModel) { + when { + uiState.isLoading -> CenteredMessage { + MillenniumBarsLoader(size = 48.dp) + } + + uiState.error != null -> CenteredMessage { + Text( + text = uiState.error ?: stringResource(Res.string.common_unknown_error), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.danger, + textAlign = TextAlign.Center ) - .clickable(onClick = onClick) - .padding(horizontal = 18.dp, vertical = 7.dp) - ) { - Text( - text = label, - style = AppTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isSelected) { - AppTheme.colors.onPrimary - } else { - AppTheme.colors.onSurfaceVariant - } + } + + uiState.mode == LyricType.SYNCED -> SyncedLyricsContent( + syncedLyrics = uiState.syncedLyrics ?: emptyList(), + currentIndex = uiState.currentIndex, + onLineClick = viewModel::seekToLine + ) + + uiState.mode == LyricType.STATIC -> PlainLyricsContent( + plainLyrics = uiState.plainLyrics ?: "" ) } } @Composable -private fun SyncedLyricsContent( - syncedLyrics: List, - currentIndex: Int, - onScrollToIndex: (Int) -> Unit) { - if (syncedLyrics.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(Res.string.lyrics_no_synced), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant, - textAlign = TextAlign.Center +private fun CoverArt(url: String, size: Dp, radius: Dp, elevation: Dp, modifier: Modifier = Modifier) { + val colors = MillenniumTheme.colors + Box( + modifier = modifier + .size(size) + .millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(radius), + borderColor = colors.border, + borderWidth = 1.dp, + shadowElevation = elevation ) - } - return - } - - val listState = rememberLazyListState() - - LaunchedEffect(currentIndex) { - if (currentIndex >= 0) { - listState.animateScrollToItem( - index = currentIndex.coerceIn(0, syncedLyrics.lastIndex), - scrollOffset = -100 - ) - } - } - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) ) { - itemsIndexed(syncedLyrics) { index, line -> - val isCurrentLine = index == currentIndex - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .then( - if (isCurrentLine) { - Modifier.background(AppTheme.colors.primaryContainer) - } else { - Modifier - } - ) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = formatLyricTime(line.time), - style = AppTheme.typography.labelSmall, - color = if (isCurrentLine) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - }, - modifier = Modifier.width(44.dp) - ) - Text( - text = line.text.ifBlank { "..." }, - modifier = Modifier.weight(1f), - style = AppTheme.typography.bodyLarge.copy( - fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal, - color = if (isCurrentLine) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurface - } - ) - ) - } - } + AsyncImage( + model = url.takeIf { it.isNotBlank() }, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) } } -private fun formatLyricTime(millis: Long): String { - val totalSeconds = (millis / 1000).coerceAtLeast(0) - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} - @Composable -private fun PlainLyricsContent( - plainLyrics: String) { - if (plainLyrics.isBlank()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { +private fun TrackPane(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CoverArt( + url = uiState.coverUrl, + size = 280.dp, + radius = 24.dp, + elevation = 14.dp + ) + Spacer(Modifier.height(24.dp)) + Text( + text = uiState.trackTitle, + style = AppTheme.typography.headlineSmall, + color = MillenniumTheme.colors.text, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun CompactTrackHeader(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CoverArt(url = uiState.coverUrl, size = 48.dp, radius = 12.dp, elevation = 4.dp) + Column(Modifier.weight(1f)) { Text( - text = stringResource(Res.string.lyrics_no_plain), + text = uiState.trackTitle, + style = AppTheme.typography.titleSmall, + color = MillenniumTheme.colors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ColumnScope.CenteredMessage(content: @Composable () -> Unit) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { content() } +} + +@Composable +private fun ColumnScope.SyncedLyricsContent( + syncedLyrics: List, + currentIndex: Int, + onLineClick: (LyricsLine) -> Unit) { + if (syncedLyrics.isEmpty()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_synced), style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant, + color = MillenniumTheme.colors.muted, textAlign = TextAlign.Center ) } return } - LazyColumn { + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = currentIndex, + onLineClick = onLineClick, + modifier = Modifier.weight(1f).fillMaxWidth() + ) +} + +@Composable +private fun ColumnScope.PlainLyricsContent( + plainLyrics: String) { + if (plainLyrics.isBlank()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_plain), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center + ) + } + return + } + + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp) + ) { item { Text( text = plainLyrics, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - style = AppTheme.typography.bodyLarge.copy(lineHeight = 30.sp), - color = AppTheme.colors.onSurface, + modifier = Modifier.fillMaxWidth(), + style = AppTheme.typography.titleMedium.copy(lineHeight = 34.sp), + color = MillenniumTheme.colors.text, textAlign = TextAlign.Center ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt index a38c3043..915d2367 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt @@ -25,13 +25,16 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.plugin.PluginManager import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.milliseconds @Serializable enum class LyricType { @@ -44,22 +47,32 @@ data class LyricsUiState( val syncedLyrics: List? = null, val plainLyrics: String? = null, val isLoading: Boolean = false, - val error: String? = null) + val error: String? = null, + val currentIndex: Int = -1, + val trackTitle: String = "", + val trackArtists: String = "", + val coverUrl: String = "") class LyricsViewModel( private val pluginManager: PluginManager, - audioPlayer: AudioPlayerInterface, + private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val _uiState = MutableStateFlow(LyricsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + + // Only re-emits when the active line changes, not on every position tick. + private val currentIndexFlow = combine( + _uiState.map { it.syncedLyrics }.distinctUntilChanged(), + audioPlayer.positionFlow + ) { lyrics, position -> activeLineIndex(lyrics, position.inWholeMilliseconds) } + .distinctUntilChanged() + + val uiState: StateFlow = combine(_uiState, currentIndexFlow) { state, index -> + state.copy(currentIndex = index) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LyricsUiState()) private var currentTrackId: String? = null - val positionMillisFlow: StateFlow = audioPlayer.positionFlow - .map { it.inWholeMilliseconds } - .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), 0L) - fun setMode(mode: LyricType) { _uiState.update { it.copy(mode = mode) } } @@ -106,6 +119,17 @@ class LyricsViewModel( is QueueEntry.StreamingTrack -> entry.track.id else -> null } + if (entry is QueueEntry.StreamingTrack) { + val track = entry.track + _uiState.update { + it.copy( + trackTitle = track.title, + trackArtists = track.artists.joinToString(", ") { artist -> artist.name }, + coverUrl = (track.album?.thumbnails ?: track.thumbnails) + ?.firstOrNull()?.url.orEmpty() + ) + } + } if (trackId != null && trackId != currentTrackId) { loadLyrics() } @@ -113,17 +137,15 @@ class LyricsViewModel( } } - fun currentLyricIndex(positionMillis: Long): Int { - val lyrics = _uiState.value.syncedLyrics ?: emptyList() - if (lyrics.isEmpty()) return -1 - var lastIndex = 0 - for (i in lyrics.indices) { - if (lyrics[i].time <= positionMillis) { - lastIndex = i - } else { - break - } + fun seekToLine(line: LyricsLine) { + viewModelScope.launch { + audioPlayer.seekTo(line.time.milliseconds) } - return lastIndex } -} \ No newline at end of file + + private fun activeLineIndex(lyrics: List?, positionMillis: Long): Int { + if (lyrics.isNullOrEmpty()) return -1 + val index = lyrics.indexOfLast { it.time <= positionMillis } + return index.coerceAtLeast(0) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt new file mode 100644 index 00000000..c204d8ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt @@ -0,0 +1,196 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.lyrics + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.theme.MillenniumTheme + +private const val LYRICS_BLANK_LINE = "\u266A" + +/** + * Time-synced lyrics list that keeps the active line vertically centered. + * + * With [onLineClick] it renders Millennium list tiles (the active one is the selected tile); + * without it, it renders a passive compact preview. + */ +@Composable +fun SyncedLyricsList( + lines: List, + currentIndex: Int, + modifier: Modifier = Modifier, + onLineClick: ((LyricsLine) -> Unit)? = null +) { + val listState = rememberLazyListState() + val colors = MillenniumTheme.colors + + BoxWithConstraints(modifier = modifier) { + // Half-viewport padding lets the first/last lines reach the center too. + val edgePadding = maxHeight / 2 + + LaunchedEffect(currentIndex, lines.size) { + if (currentIndex !in lines.indices) return@LaunchedEffect + val visible = listState.layoutInfo.visibleItemsInfo + val itemHeight = visible.firstOrNull { it.index == currentIndex }?.size + ?: visible.firstOrNull()?.size + ?: 0 + listState.animateScrollToItem(index = currentIndex, scrollOffset = itemHeight / 2) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().then( + if (onLineClick != null) Modifier.fadingEdges(maxHeight * 0.18f) else Modifier + ), + contentPadding = PaddingValues(vertical = edgePadding), + verticalArrangement = Arrangement.spacedBy(if (onLineClick != null) 4.dp else 2.dp) + ) { + itemsIndexed(lines, key = { index, line -> "$index-${line.time}" }) { index, line -> + val isCurrent = index == currentIndex + val text = line.text.ifBlank { LYRICS_BLANK_LINE } + val color by animateColorAsState( + when { + isCurrent -> colors.accent + index < currentIndex -> colors.muted.copy(alpha = 0.55f) + else -> colors.muted + } + ) + if (onLineClick != null) { + ImmersiveLyricLine( + text = text, + distance = kotlin.math.abs(index - currentIndex), + isCurrent = isCurrent, + onClick = { onLineClick(line) } + ) + } else { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + style = AppTheme.typography.bodyMedium.copy( + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + color = color + ), + textAlign = TextAlign.Center, + maxLines = 2 + ) + } + } + } + } +} + +@Composable +private fun ImmersiveLyricLine( + text: String, + distance: Int, + isCurrent: Boolean, + onClick: () -> Unit +) { + val colors = MillenniumTheme.colors + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val targetAlpha = when (distance) { + 0 -> 1f + 1 -> 0.7f + 2 -> 0.5f + else -> 0.32f + } + val alpha by animateFloatAsState(targetAlpha, tween(300)) + val scale by animateFloatAsState(if (isCurrent) 1f else 0.9f, tween(300)) + val color by animateColorAsState(if (isCurrent) colors.accent else colors.text, tween(300)) + val hoverBackground by animateColorAsState( + if (hovered && !isCurrent) colors.accent.copy(alpha = 0.10f) else Color.Transparent + ) + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = if (hovered) maxOf(alpha, 0.85f) else alpha + } + .clip(RoundedCornerShape(14.dp)) + .background(hoverBackground) + .hoverable(interaction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + style = AppTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + color = color + ), + textAlign = TextAlign.Center + ) +} + +/** Fades the top and bottom [fade] of the content to transparent. */ +private fun Modifier.fadingEdges(fade: Dp): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fraction = (fade.toPx() / size.height).coerceIn(0f, 0.5f) + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Transparent, + fraction to Color.Black, + 1f - fraction to Color.Black, + 1f to Color.Transparent + ), + blendMode = BlendMode.DstIn + ) + } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 22208484..9afbafcd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -39,11 +39,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -62,14 +59,12 @@ import org.spotube.millennium.theme.millenniumPanelBrush import org.spotube.millennium.theme.millenniumSurface import com.composeunstyled.SheetDetent import com.composeunstyled.rememberModalBottomSheetState -import androidx.compose.ui.graphics.SolidColor import com.composeunstyled.Text import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -99,6 +94,7 @@ import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadStatus import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel +import dev.krtirtho.spotube.modules.lyrics.SyncedLyricsList import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -659,7 +655,6 @@ fun AppExpandedPlayer( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), - audioPlayer = audioPlayer, onExpand = onExpandLyrics ) } @@ -690,105 +685,50 @@ private fun rememberSharedAlbumArtModifier( @Composable private fun LyricsPreviewCard( modifier: Modifier = Modifier, - audioPlayer: AudioPlayerInterface, onExpand: () -> Unit, viewModel: LyricsViewModel = koinViewModel() ) { val uiState by viewModel.uiState.collectAsState() val syncedLyrics = uiState.syncedLyrics ?: emptyList() val isLoading = uiState.isLoading - - val listState = rememberLazyListState() - val positionMillis by audioPlayer.positionFlow.collectAsState(initial = 0.milliseconds) - - val currentIndex by remember { - derivedStateOf { - if (syncedLyrics.isEmpty()) return@derivedStateOf -1 - var idx = 0 - for (i in syncedLyrics.indices) { - if (syncedLyrics[i].time <= positionMillis.inWholeMilliseconds) { - idx = i - } else { - break - } - } - idx - } - } - - LaunchedEffect(currentIndex) { - if (currentIndex >= 0 && syncedLyrics.isNotEmpty()) { - val centerIndex = currentIndex.coerceIn(0, syncedLyrics.lastIndex) - listState.animateScrollToItem(centerIndex, scrollOffset = -50) - } - } - if (syncedLyrics.isEmpty() || isLoading) return - Box( - modifier = modifier - .millenniumSurface( - background = SolidColor(AppTheme.colors.surfaceContainerHighest), - shape = RoundedCornerShape(16.dp), - shadowElevation = 2.dp - ) - .clickable(onClick = onExpand) + MillenniumCard( + modifier = modifier.clickable(onClick = onExpand), + contentPadding = PaddingValues(12.dp), + contentSpacing = 8.dp, + shadowElevation = 2.dp ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.player_lyrics), + style = AppTheme.typography.labelMedium, + color = MillenniumTheme.colors.muted + ) + MillenniumIconButton( + onClick = onExpand, + style = MillenniumIconButtonStyle.Quiet ) { - Text( - text = stringResource(Res.string.player_lyrics), - style = AppTheme.typography.labelMedium, - color = AppTheme.colors.onSurfaceVariant + MillenniumIcon( + Iconsax.IconsaxArrowSquareUp, + contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), + tint = MillenniumTheme.colors.muted ) - MillenniumIconButton( - onClick = onExpand, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - Iconsax.IconsaxArrowSquareUp, - contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), - tint = AppTheme.colors.onSurfaceVariant - ) - } - } - - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxWidth() - .height(120.dp), - contentPadding = PaddingValues(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - val displayLines = syncedLyrics.take(6) - itemsIndexed(displayLines) { index, line -> - val isCurrent = - index == currentIndex.coerceIn(0, displayLines.lastIndex.coerceAtLeast(0)) - Text( - text = line.text.ifBlank { "..." }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - style = AppTheme.typography.bodySmall.copy( - fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, - color = if (isCurrent) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f) - } - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) - } } } + + // Passive preview: no onLineClick, so lines never seek playback. + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = uiState.currentIndex, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) } } From 2474c26a836bc8f25f7e21882b42dce5fa86e810 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 15:37:15 +0600 Subject: [PATCH 064/132] refactor: move package name from dev.krtirtho to org.spotube --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- AGENTS.md | 8 +- CHANGELOG.md | 1478 ++++++++--------- CONTRIBUTION.md | 20 +- README.md | 10 +- cliff.toml | 2 +- composeApp/build.gradle.kts | 10 +- .../spotube/core/newpipe/NewPipeDownloader.kt | 4 +- .../core/newpipe/NewPipeService.androidJvm.kt | 6 +- .../spotube}/spotube/MainActivity.kt | 10 +- .../spotube}/spotube/MyApplication.kt | 6 +- .../spotube}/spotube/Platform.android.kt | 2 +- .../core/audioplayer/AudioPlayer.android.kt | 4 +- .../spotube/core/di/Modules.android.kt | 18 +- .../LocalNetworkPermission.android.kt | 2 +- .../spotube/core/i18n/AppLocale.android.kt | 2 +- .../spotube/core/paths/Paths.android.kt | 2 +- .../spotube/core/share/AndroidShareService.kt | 4 +- .../webview/PlatformWebviewConfig.android.kt | 2 +- .../core/zipline/ZiplineDispatcher.android.kt | 2 +- .../spotube/media/MediaBrowseHelper.kt | 38 +- .../spotube}/spotube/media/PlaybackService.kt | 18 +- .../AndroidLocalMediaDiscoveryService.kt | 4 +- .../media/LocalMediaPermission.android.kt | 2 +- .../modules/blacklist/BlacklistViewModek.kt | 2 - .../spotube/resources/iconsax/Iconsax.kt | 3 - .../krtirtho => org/spotube}/spotube/App.kt | 76 +- .../spotube}/spotube/Platform.kt | 2 +- .../spotube}/spotube/core/DebugConfig.kt | 4 +- .../spotube/core/audioplayer/AudioPlayer.kt | 2 +- .../core/audioplayer/AudioPlayerModels.kt | 6 +- .../core/audioplayer/AudioPlayerQueue.kt | 2 +- .../audioplayer/AudioPlayerQueueRepository.kt | 6 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 12 +- .../spotube}/spotube/core/db/Database.kt | 4 +- .../core/deeplink/ExternalUriHandler.kt | 2 +- .../spotube}/spotube/core/di/EnrichKoin.kt | 2 +- .../spotube}/spotube/core/di/Init.kt | 2 +- .../spotube}/spotube/core/di/Modules.kt | 148 +- .../spotube/core/discord/DiscordRpcService.kt | 12 +- .../core/discovery/DeviceDiscoveryService.kt | 2 +- .../core/discovery/LocalNetworkPermission.kt | 2 +- .../spotube/core/extras/CaseConverterExt.kt | 2 +- .../spotube}/spotube/core/i18n/AppLocale.kt | 4 +- .../spotube}/spotube/core/jam/JamProtocol.kt | 10 +- .../spotube/core/jam/JamRoomClient.kt | 4 +- .../spotube}/spotube/core/jam/JamRoomCode.kt | 2 +- .../spotube/core/jam/JamRoomService.kt | 22 +- .../spotube/core/navigation/NavTransitions.kt | 2 +- .../core/navigation/NavigationCommands.kt | 2 +- .../core/navigation/NavigationModule.kt | 38 +- .../core/navigation/NavigationState.kt | 4 +- .../spotube/core/newpipe/NewPipeModels.kt | 4 +- .../spotube/core/newpipe/NewPipeService.kt | 2 +- .../spotube}/spotube/core/paths/Paths.kt | 2 +- .../core/playback/CollectionPlaybackHelper.kt | 22 +- .../core/remote/ConnectionRequestDialog.kt | 4 +- .../core/remote/RemoteControlClient.kt | 2 +- .../core/remote/RemoteControlHandler.kt | 18 +- .../core/remote/RemoteControlProtocol.kt | 4 +- .../core/remote/RemoteControlService.kt | 8 +- .../core/remote/RemotePlaybackController.kt | 16 +- .../spotube/core/scrobble/ScrobbleManager.kt | 18 +- .../server/AlternativeTracksRepository.kt | 12 +- .../spotube/core/server/CacheManager.kt | 8 +- .../spotube/core/server/LocalServer.kt | 10 +- .../core/server/MatchTracksRepository.kt | 8 +- .../spotube/core/server/StreamProxy.kt | 12 +- .../core/server/StreamingUrlRepository.kt | 24 +- .../spotube/core/share/ShareService.kt | 2 +- .../core/tools/user_agents/UserAgents.kt | 8 +- .../ui/component/AdaptiveDialogBottomSheet.kt | 8 +- .../component/AdaptiveDropdownBottomSheet.kt | 14 +- .../spotube/core/ui/component/AlbumCard.kt | 18 +- .../core/ui/component/ApplicationMainBar.kt | 8 +- .../spotube/core/ui/component/ArtistCard.kt | 10 +- .../core/ui/component/CollectionDetails.kt | 26 +- .../core/ui/component/CollectionView.kt | 22 +- .../spotube/core/ui/component/ErrorDisplay.kt | 6 +- .../core/ui/component/FeaturedCarousel.kt | 34 +- .../core/ui/component/LikedTracksCard.kt | 26 +- .../core/ui/component/LoginRequiredDisplay.kt | 4 +- .../spotube/core/ui/component/PlaylistCard.kt | 18 +- .../ui/component/SharedTransitionLocals.kt | 2 +- .../spotube/core/ui/component/TrackCard.kt | 12 +- .../spotube/core/ui/component/TrackList.kt | 46 +- .../spotube/core/ui/component/TrackOptions.kt | 34 +- .../spotube/core/ui/component/UserCard.kt | 8 +- .../core/ui/component/VerticalScrollbar.kt | 6 +- .../core/ui/component/cards/AvatarCard.kt | 12 +- .../core/ui/component/cards/AvatarListTile.kt | 6 +- .../ui/component/cards/MediaCardSurface.kt | 2 +- .../ui/component/cards/MediaEmptyState.kt | 4 +- .../core/ui/component/cards/PlayableCard.kt | 22 +- .../ui/component/cards/PlayableListTile.kt | 24 +- .../spotube/core/ui/coverflow/Coverflow.kt | 2 +- .../core/ui/coverflow/CoverflowGeometry.kt | 2 +- .../core/ui/coverflow/CoverflowItem.kt | 2 +- .../core/ui/coverflow/CoverflowParams.kt | 2 +- .../core/ui/coverflow/CoverflowScope.kt | 2 +- .../core/ui/coverflow/CoverflowState.kt | 2 +- .../spotube/core/ui/coverflow/Mirror.kt | 2 +- .../spotube}/spotube/core/ui/misc/Shimmer.kt | 2 +- .../spotube/core/ui/theming/AppTheme.kt | 6 +- .../spotube/core/webview/PlatformWebView.kt | 10 +- .../core/webview/PlatformWebviewConfig.kt | 2 +- .../core/webview/PlatformWebviewScreen.kt | 8 +- .../core/zipline/BuiltInPluginService.kt | 20 +- .../core/zipline/FileSystemHTTPClient.kt | 4 +- .../spotube/core/zipline/PluginService.kt | 24 +- .../spotube/core/zipline/ZiplineDispatcher.kt | 2 +- .../core/zipline/ZiplinePluginService.kt | 90 +- .../core/zipline/host_apis/RealCryptoAPI.kt | 64 +- .../zipline/host_apis/RealHttpClientAPI.kt | 10 +- .../host_apis/RealPersistedStorageAPI.kt | 8 +- .../host_apis/RealSystemInformationAPI.kt | 6 +- .../core/zipline/host_apis/RealWebViewAPI.kt | 8 +- .../core/zipline/host_apis/additionals/DES.kt | 6 +- .../zipline/plugin_apis/common/RealCoreAPI.kt | 6 +- .../plugin_apis/lrclib/RealLRCLibLyricsAPI.kt | 10 +- .../plugin_apis/newpipe_yt/ISRCModels.kt | 2 +- .../plugin_apis/newpipe_yt/ISRCProviders.kt | 8 +- .../newpipe_yt/RealNewPipeAudioAPI.kt | 22 +- .../spotube/modules/album/AlbumRepository.kt | 14 +- .../spotube/modules/album/AlbumScreen.kt | 20 +- .../spotube/modules/album/AlbumViewModel.kt | 34 +- .../modules/artist/ArtistRepository.kt | 18 +- .../spotube/modules/artist/ArtistScreen.kt | 54 +- .../spotube/modules/artist/ArtistViewModel.kt | 36 +- .../modules/blacklist/BlacklistRepository.kt | 8 +- .../modules/blacklist/BlacklistScreen.kt | 26 +- .../modules/blacklist/BlacklistViewModek.kt | 2 + .../modules/blacklist/BlacklistViewModel.kt | 6 +- .../spotube/modules/devices/DevicesScreen.kt | 22 +- .../modules/devices/DevicesViewModel.kt | 20 +- .../modules/devices/PlayDestinationPicker.kt | 18 +- .../modules/devices/RemoteControlScreen.kt | 38 +- .../modules/devices/RemoteControlViewModel.kt | 12 +- .../modules/downloads/DownloadManager.kt | 12 +- .../modules/downloads/DownloadProgressIcon.kt | 6 +- .../modules/downloads/DownloadsScreen.kt | 22 +- .../modules/downloads/DownloadsViewModel.kt | 4 +- .../spotube/modules/home/HomeScreen.kt | 54 +- .../modules/home/HomeScreenRepository.kt | 14 +- .../modules/home/HomeScreenViewModel.kt | 16 +- .../spotube}/spotube/modules/jam/JamScreen.kt | 20 +- .../spotube/modules/jam/JamViewModel.kt | 20 +- .../modules/jam/PlayDestinationPicker.kt | 4 +- .../modules/library/LibraryMediaCollection.kt | 6 +- .../modules/library/LibraryRepository.kt | 16 +- .../spotube/modules/library/LibraryScreen.kt | 30 +- .../spotube/modules/library/LibraryState.kt | 2 +- .../spotube/modules/library/LibraryTab.kt | 14 +- .../library/album/LibraryAlbumsScreen.kt | 14 +- .../library/album/LibraryAlbumsViewModel.kt | 14 +- .../library/artist/LibraryArtistsScreen.kt | 14 +- .../library/artist/LibraryArtistsViewModel.kt | 14 +- .../local_tracks/LibraryLocalTracksScreen.kt | 26 +- .../LibraryLocalTracksViewModel.kt | 20 +- .../media/LocalMediaCacheRepository.kt | 6 +- .../media/LocalMediaDiscoveryService.kt | 2 +- .../media/LocalMediaFoldersConfig.kt | 6 +- .../media/LocalMediaLibraryCoordinator.kt | 4 +- .../local_tracks/media/LocalMediaModels.kt | 2 +- .../media/LocalMediaPermission.kt | 2 +- .../media/NoopLocalMediaDiscoveryService.kt | 2 +- .../library/playlist/AddToPlaylistPicker.kt | 14 +- .../playlist/LibraryPlaylistsScreen.kt | 16 +- .../playlist/LibraryPlaylistsViewModel.kt | 14 +- .../library/playlist/PlaylistFormSheet.kt | 6 +- .../spotube/modules/lyrics/LyricsScreen.kt | 12 +- .../spotube/modules/lyrics/LyricsViewModel.kt | 12 +- .../modules/lyrics/SyncedLyricsList.kt | 6 +- .../playlist/AddTracksToPlaylistDialog.kt | 20 +- .../modules/playlist/PlaylistRepository.kt | 14 +- .../modules/playlist/PlaylistScreen.kt | 28 +- .../modules/playlist/PlaylistViewModel.kt | 36 +- .../spotube/modules/plugin/BuiltInPlugins.kt | 2 +- .../modules/plugin/GitHubPluginRepo.kt | 2 +- .../modules/plugin/MetadataAuthViewModel.kt | 6 +- .../spotube/modules/plugin/PluginManager.kt | 16 +- .../spotube/modules/plugin/PluginModels.kt | 2 +- .../spotube/modules/plugin/PluginScreen.kt | 82 +- .../spotube/modules/plugin/PluginViewModel.kt | 8 +- .../components/DefaultAbilityPluginGrid.kt | 28 +- .../modules/plugin/components/PluginCard.kt | 26 +- .../plugin/components/PluginDialogParts.kt | 12 +- .../plugin/components/PluginInstallDialog.kt | 14 +- .../modules/plugin/components/PluginLogo.kt | 6 +- .../components/PluginPermissionsDialog.kt | 22 +- .../plugin/guard/MetadataPluginGuard.kt | 22 +- .../guard/MetadataPluginGuardViewModel.kt | 16 +- .../saved_tracks/SavedTracksRepository.kt | 10 +- .../modules/saved_tracks/SavedTracksScreen.kt | 22 +- .../saved_tracks/SavedTracksViewModel.kt | 30 +- .../modules/search/SearchRepository.kt | 22 +- .../spotube/modules/search/SearchScreen.kt | 80 +- .../modules/search/SearchScreenViewModel.kt | 22 +- .../modules/settings/JamSettingsScreen.kt | 32 +- .../modules/settings/JamSettingsViewModel.kt | 4 +- .../settings/LocalMediaFoldersViewModel.kt | 2 +- .../modules/settings/SettingsConstants.kt | 2 +- .../modules/settings/SettingsModels.kt | 6 +- .../modules/settings/SettingsRepository.kt | 6 +- .../modules/settings/SettingsScreen.kt | 36 +- .../modules/settings/SettingsSidebarLayout.kt | 62 +- .../modules/settings/SettingsViewModel.kt | 2 +- .../components/InteractiveSettingCards.kt | 12 +- .../components/LocalMediaFoldersEditor.kt | 22 +- .../settings/components/SettingsCardItem.kt | 4 +- .../settings/sections/AppearanceSection.kt | 20 +- .../modules/settings/sections/CacheSection.kt | 22 +- .../settings/sections/DesktopSection.kt | 14 +- .../settings/sections/DownloadsSection.kt | 26 +- .../modules/settings/sections/JamSection.kt | 16 +- .../sections/LanguageRegionSection.kt | 18 +- .../settings/sections/PlaybackSection.kt | 36 +- .../settings/sections/PluginsSection.kt | 14 +- .../settings/sections/SupportSection.kt | 18 +- .../settings/sections/UpdatesSection.kt | 12 +- .../modules/settings/sections/utils.kt | 8 +- .../spotube/modules/shell/AppBottombar.kt | 14 +- .../modules/shell/AppExpandedPlayer.kt | 74 +- .../modules/shell/AppFloatingPlayer.kt | 20 +- .../spotube/modules/shell/AppLargePlayer.kt | 68 +- .../spotube/modules/shell/AppPlayerState.kt | 12 +- .../spotube/modules/shell/AppShell.kt | 30 +- .../modules/shell/AppShellViewModel.kt | 2 +- .../spotube/modules/shell/AppSidebar.kt | 30 +- .../modules/shell/PlayerArtistsText.kt | 4 +- .../modules/shell/PlayerHeartButton.kt | 20 +- .../modules/shell/PlayerOptionDialogs.kt | 16 +- .../modules/shell/PlayerOptionsViewModel.kt | 30 +- .../spotube/modules/shell/PlayerTimeFormat.kt | 2 +- .../AlternativeTrackContent.kt | 10 +- .../AlternativeTrackContentViewModel.kt | 12 +- .../AlternativeTrackSheet.kt | 6 +- .../shell/player_queue/PlayerQueueContent.kt | 22 +- .../PlayerQueueContentViewModel.kt | 12 +- .../modules/shell/player_queue/QueueSheet.kt | 2 +- .../spotube/modules/support/Support.kt | 14 +- .../spotube/modules/update/AppUpdateDialog.kt | 2 +- .../modules/update/AppUpdateService.kt | 8 +- .../modules/update/PluginUpdateDialog.kt | 4 +- .../modules/update/PluginUpdateService.kt | 16 +- .../spotube/modules/webview/WebViewScreen.kt | 8 +- .../spotube/modules/welcome/WelcomeContent.kt | 2 +- .../spotube/modules/welcome/WelcomeModels.kt | 4 +- .../modules/welcome/WelcomeRepository.kt | 8 +- .../spotube/modules/welcome/WelcomeScreen.kt | 22 +- .../modules/welcome/WelcomeViewModel.kt | 6 +- .../spotube/modules/welcome/WelcomeVisuals.kt | 4 +- .../modules/welcome/pages/BrandingPage.kt | 6 +- .../modules/welcome/pages/DonationPage.kt | 6 +- .../modules/welcome/pages/HowItWorksPage.kt | 14 +- .../modules/welcome/pages/PluginsPage.kt | 4 +- .../spotube/resources/iconsax/ArrowLeft3.kt | 2 +- .../resources/iconsax/BrandBuyMeACoffee.kt | 2 +- .../resources/iconsax/BrandOpencollective.kt | 2 +- .../spotube/resources/iconsax/BrandPatreon.kt | 2 +- .../resources/iconsax/CarbonGithubLogo.kt | 2 +- .../spotube/resources/iconsax/CustomServer.kt | 2 +- .../resources/iconsax/FluentDismiss.kt | 2 +- .../resources/iconsax/FluentMaximize.kt | 2 +- .../spotube/resources/iconsax/FluentMinus.kt | 2 +- .../resources/iconsax/FluentSquareMultiple.kt | 2 +- .../spotube/resources/iconsax/Iconsax.kt | 3 + .../resources/iconsax/Iconsax3DotsMore.kt | 2 +- .../spotube/resources/iconsax/IconsaxAdd.kt | 2 +- .../resources/iconsax/IconsaxAddSquare.kt | 2 +- .../resources/iconsax/IconsaxArrowDown4.kt | 2 +- .../resources/iconsax/IconsaxArrowSquareUp.kt | 2 +- .../spotube/resources/iconsax/IconsaxBox.kt | 2 +- .../resources/iconsax/IconsaxBoxAdd.kt | 2 +- .../spotube/resources/iconsax/IconsaxCd.kt | 2 +- .../resources/iconsax/IconsaxCheckCircle.kt | 2 +- .../resources/iconsax/IconsaxCheckSquare.kt | 2 +- .../resources/iconsax/IconsaxCloseSquare.kt | 2 +- .../resources/iconsax/IconsaxColorSwatch.kt | 2 +- .../resources/iconsax/IconsaxColorsSquare.kt | 2 +- .../iconsax/IconsaxDirectboxReceive.kt | 2 +- .../iconsax/IconsaxDocumentDownload.kt | 2 +- .../resources/iconsax/IconsaxDocumentText.kt | 2 +- .../resources/iconsax/IconsaxDragHandle.kt | 2 +- .../spotube/resources/iconsax/IconsaxEdit.kt | 2 +- .../iconsax/IconsaxExportArrowBulk.kt | 2 +- .../spotube/resources/iconsax/IconsaxEye.kt | 2 +- .../resources/iconsax/IconsaxFilterSearch.kt | 2 +- .../resources/iconsax/IconsaxFolder.kt | 2 +- .../resources/iconsax/IconsaxFolderAdd.kt | 2 +- .../resources/iconsax/IconsaxFolderOpen.kt | 2 +- .../resources/iconsax/IconsaxForbidden.kt | 2 +- .../resources/iconsax/IconsaxFormatCircle.kt | 2 +- .../spotube/resources/iconsax/IconsaxGlobe.kt | 2 +- .../spotube/resources/iconsax/IconsaxHeart.kt | 2 +- .../resources/iconsax/IconsaxHeart2.kt | 2 +- .../spotube/resources/iconsax/IconsaxHome.kt | 2 +- .../resources/iconsax/IconsaxHomeBroken.kt | 2 +- .../resources/iconsax/IconsaxImportArrow2.kt | 2 +- .../iconsax/IconsaxImportArrow2Bulk.kt | 2 +- .../resources/iconsax/IconsaxInformation.kt | 2 +- .../iconsax/IconsaxLanguageSquare.kt | 2 +- .../spotube/resources/iconsax/IconsaxLink.kt | 2 +- .../resources/iconsax/IconsaxLocation.kt | 2 +- .../spotube/resources/iconsax/IconsaxMagic.kt | 2 +- .../resources/iconsax/IconsaxMinusSquare.kt | 2 +- .../iconsax/IconsaxMirrorScreenRegular.kt | 2 +- .../iconsax/IconsaxMirroringScreen.kt | 2 +- .../spotube/resources/iconsax/IconsaxMoon.kt | 2 +- .../spotube/resources/iconsax/IconsaxMusic.kt | 2 +- .../resources/iconsax/IconsaxMusicCircle.kt | 2 +- .../iconsax/IconsaxMusicDashboard.kt | 2 +- .../resources/iconsax/IconsaxMusicFilter.kt | 2 +- .../resources/iconsax/IconsaxMusicLibrary.kt | 2 +- .../iconsax/IconsaxMusicLibraryOutline.kt | 2 +- .../resources/iconsax/IconsaxMusicPlay.kt | 2 +- .../resources/iconsax/IconsaxMusicPlaylist.kt | 2 +- .../iconsax/IconsaxMusicSquareRemove.kt | 2 +- .../iconsax/IconsaxMusicSquareRemoveFilled.kt | 2 +- .../spotube/resources/iconsax/IconsaxNext.kt | 2 +- .../spotube/resources/iconsax/IconsaxPause.kt | 2 +- .../resources/iconsax/IconsaxPauseCircle.kt | 2 +- .../spotube/resources/iconsax/IconsaxPlay.kt | 2 +- .../resources/iconsax/IconsaxPlayCircle.kt | 2 +- .../resources/iconsax/IconsaxPlayCircle2.kt | 2 +- .../resources/iconsax/IconsaxPrevious.kt | 2 +- .../resources/iconsax/IconsaxRefreshArrow2.kt | 2 +- .../resources/iconsax/IconsaxRefreshRight.kt | 2 +- .../resources/iconsax/IconsaxRepeatArrow.kt | 2 +- .../resources/iconsax/IconsaxRepeatMusic.kt | 2 +- .../resources/iconsax/IconsaxRepeateMusic.kt | 2 +- .../resources/iconsax/IconsaxRepeateOne.kt | 2 +- .../resources/iconsax/IconsaxSearch.kt | 2 +- .../resources/iconsax/IconsaxSearchBroken.kt | 2 +- .../resources/iconsax/IconsaxSetting.kt | 2 +- .../resources/iconsax/IconsaxSetting2.kt | 2 +- .../iconsax/IconsaxSettingTwotone.kt | 2 +- .../spotube/resources/iconsax/IconsaxShare.kt | 2 +- .../resources/iconsax/IconsaxShuffle.kt | 2 +- .../iconsax/IconsaxSidebarLeftBroken.kt | 2 +- .../iconsax/IconsaxSidebarRightBroken.kt | 2 +- .../spotube/resources/iconsax/IconsaxSort.kt | 2 +- .../spotube/resources/iconsax/IconsaxSound.kt | 2 +- .../resources/iconsax/IconsaxSoundTwotone.kt | 2 +- .../resources/iconsax/IconsaxSquare.kt | 2 +- .../resources/iconsax/IconsaxStopCircle.kt | 2 +- .../spotube/resources/iconsax/IconsaxTag.kt | 2 +- .../resources/iconsax/IconsaxTextalignLeft.kt | 2 +- .../spotube/resources/iconsax/IconsaxTrash.kt | 2 +- .../resources/iconsax/IconsaxUserRemove.kt | 2 +- .../iconsax/IconsaxVerticalSetting.kt | 2 +- .../resources/iconsax/IconsaxVolumeCross.kt | 2 +- .../resources/iconsax/IconsaxVolumeHigh.kt | 2 +- .../resources/iconsax/IconsaxVolumeLow.kt | 2 +- .../resources/iconsax/IconsaxWifiSquare.kt | 2 +- .../resources/iconsax/InconsaxClock.kt | 2 +- .../spotube/resources/iconsax/LineDiscord.kt | 2 +- .../iconsax/PhosphorDatabaseDuotone.kt | 2 +- .../spotube/resources/iconsax/Play.kt | 2 +- .../resources/iconsax/SwapHorizontal2.kt | 2 +- .../spotube/resources/iconsax/User.kt | 2 +- .../templates/AppBuildInfo.kt.template | 2 +- .../core/audioplayer/AudioPlayerQueueTest.kt | 2 +- .../audioplayer/DeviceAudioPlayerQueueTest.kt | 16 +- .../core/audioplayer/FakeAudioPlayer.kt | 2 +- .../FakeAudioPlayerQueueRepository.kt | 2 +- .../audioplayer/FakeBlacklistRepository.kt | 12 +- .../core/audioplayer/FakePluginProvider.kt | 16 +- .../core/audioplayer/FakeSettingsProvider.kt | 6 +- .../core/scrobble/ScrobbleManagerTest.kt | 30 +- .../StreamingUrlRepositoryBehaviorTest.kt | 16 +- .../server/StreamingUrlRepositoryFakes.kt | 26 +- .../core/server/StreamingUrlRepositoryTest.kt | 10 +- .../zipline/host_apis/additionals/DESTest.kt | 6 +- .../spotube}/spotube/MainViewController.kt | 4 +- .../spotube}/spotube/Platform.ios.kt | 2 +- .../core/audioplayer/AudioPlayer.ios.kt | 2 +- .../spotube}/spotube/core/di/Modules.ios.kt | 18 +- .../discovery/LocalNetworkPermission.ios.kt | 2 +- .../spotube/core/i18n/AppLocale.ios.kt | 2 +- .../core/newpipe/NewPipeService.ios.kt | 4 +- .../spotube}/spotube/core/paths/Paths.ios.kt | 2 +- .../spotube/core/share/IosShareService.kt | 4 +- .../core/webview/PlatformWebviewConfig.ios.kt | 2 +- .../core/zipline/ZiplineDispatcher.ios.kt | 2 +- .../media/IosLocalMediaDiscoveryService.kt | 2 +- .../media/LocalMediaPermission.ios.kt | 2 +- .../spotube}/spotube/Platform.jvm.kt | 2 +- .../core/audioplayer/AudioPlayer.jvm.kt | 6 +- .../spotube}/spotube/core/di/Modules.jvm.kt | 20 +- .../discovery/LocalNetworkPermission.jvm.kt | 2 +- .../spotube/core/i18n/AppLocale.jvm.kt | 2 +- .../mediacontrol/SystemMediaControlService.kt | 10 +- .../spotube}/spotube/core/paths/Paths.jvm.kt | 2 +- .../spotube/core/share/JvmShareService.kt | 4 +- .../spotube/core/systemtray/SystemTray.kt | 8 +- .../core/systemtray/SystemTrayService.kt | 16 +- .../ui/component/ApplicationMainBar.jvm.kt | 16 +- .../core/ui/component/LocalApplication.kt | 2 +- .../spotube/core/ui/component/LocalWindow.kt | 2 +- .../ui/component/VerticalScrollbar.jvm.kt | 4 +- .../core/webview/PlatformWebviewConfig.jvm.kt | 4 +- .../core/zipline/ZiplineDispatcher.jvm.kt | 2 +- .../krtirtho => org/spotube}/spotube/main.kt | 28 +- .../media/JvmLocalMediaDiscoveryService.kt | 2 +- .../media/LocalMediaPermission.jvm.kt | 2 +- .../core/audioplayer/AudioPlayerTest.kt | 4 +- .../core/i18n/AppLocaleProviderTest.kt | 4 +- .../spotube/modules/jam/JamLayoutTest.kt | 28 +- .../modules/library/LibraryToolbarTest.kt | 8 +- .../modules/library/MediaLibraryUiTest.kt | 42 +- .../settings/LocalMediaFoldersEditorTest.kt | 6 +- .../ui/component/ApplicationMainBar.mobile.kt | 2 +- .../ui/component/VerticalScrollbar.mobile.kt | 2 +- iosApp/Configuration/Config.xcconfig | 2 +- iosApp/iosApp/Info.plist | 2 +- js_plugin_example/build.gradle.kts | 2 +- .../dev/krtirtho/js_plugin_example/js.kt | 84 - .../org/spotube/js_plugin_example/js.kt | 84 + .../plugin_apis/audio/RealAudioAPI.kt | 14 +- .../plugin_apis/core/RealCoreAPI.kt | 6 +- .../plugin_apis/lyrics/RealLyricsAPI.kt | 8 +- .../plugin_apis/metadata/FakeMetadataStore.kt | 26 +- .../metadata/RealMetadataAlbumAPI.kt | 12 +- .../metadata/RealMetadataArtistAPI.kt | 18 +- .../metadata/RealMetadataBrowseAPI.kt | 14 +- .../metadata/RealMetadataPlaylistAPI.kt | 12 +- .../metadata/RealMetadataSearchAPI.kt | 12 +- .../metadata/RealMetadataTrackAPI.kt | 10 +- .../metadata/RealMetadataUserAPI.kt | 6 +- .../plugin_apis/scrobble/RealScrobbleAPI.kt | 6 +- packaging/aur/.SRCINFO | 4 +- packaging/aur/PKGBUILD | 4 +- packaging/chocolatey/spotube.nuspec | 16 +- packaging/chocolatey/tools/VERIFICATION.txt | 4 +- plugin_interfaces/build.gradle.kts | 14 +- .../core/Initializer.android.kt | 2 +- .../browser_apis/BrowserPolyfills.android.kt | 2 +- .../extras/logger/Logger.android.kt | 2 +- .../plugin_interfaces/core/Initializer.kt | 2 +- .../core/browser_apis/BrowserPolyfills.kt | 2 +- .../plugin_interfaces/extras/logger/Logger.kt | 2 +- .../extras/spotor/HeadersBuilder.kt | 2 +- .../extras/spotor/HttpRequestBuilder.kt | 4 +- .../extras/spotor/Interceptor.kt | 2 +- .../extras/spotor/Serializers.kt | 2 +- .../extras/spotor/SpotrClient.kt | 6 +- .../extras/spotor/SpotrClientConfig.kt | 2 +- .../extras/spotor/SpotrHttpResponse.kt | 2 +- .../extras/spotor/URLBuilder.kt | 2 +- .../plugin_interfaces/host_apis/CryptoAPI.kt | 2 +- .../host_apis/HttpClientAPI.kt | 2 +- .../host_apis/PersistedStorageAPI.kt | 2 +- .../host_apis/SystemInformationAPI.kt | 2 +- .../plugin_interfaces/host_apis/WebViewAPI.kt | 2 +- .../plugin_apis/audio/AudioAPI.kt | 4 +- .../plugin_apis/audio/AudioModels.kt | 4 +- .../plugin_apis/core/CoreAPI.kt | 2 +- .../plugin_apis/core/CoreAPIModels.kt | 2 +- .../plugin_apis/lyrics/LyricsAPI.kt | 4 +- .../plugin_apis/lyrics/LyricsModels.kt | 2 +- .../metadata/album/MetadataAlbumAPI.kt | 8 +- .../metadata/album/MetadataAlbumModels.kt | 6 +- .../metadata/artist/MetadataArtistAPI.kt | 12 +- .../metadata/artist/MetadataArtistModels.kt | 12 +- .../metadata/browse/MetadataBrowseAPI.kt | 6 +- .../metadata/browse/MetadataBrowseModels.kt | 12 +- .../metadata/common/CommonModels.kt | 2 +- .../metadata/playlist/MetadataPlaylistAPI.kt | 8 +- .../playlist/MetadataPlaylistModels.kt | 6 +- .../metadata/search/MetadataSearchAPI.kt | 6 +- .../metadata/search/MetadataSearchModels.kt | 12 +- .../metadata/track/MetadataTrackAPI.kt | 6 +- .../metadata/track/MetadataTrackModels.kt | 8 +- .../metadata/user/MetadataUserAPI.kt | 2 +- .../metadata/user/MetadataUserModels.kt | 4 +- .../plugin_apis/scrobble/ScrobbleAPI.kt | 2 +- .../plugin_apis/scrobble/ScrobbleModels.kt | 2 +- .../audio/AudioQualitySerializationTest.kt | 2 +- .../plugin_interfaces/core/Initializer.ios.kt | 2 +- .../core/browser_apis/BrowserPolyfills.ios.kt | 2 +- .../extras/logger/Logger.ios.kt | 2 +- .../plugin_interface/host_apis/.gitkeep | 0 .../plugin_interfaces/core/Initializer.js.kt | 4 +- .../core/browser_apis/BrowserPolyfills.js.kt | 2 +- .../browser_apis/FastTextEncodingPolyfill.kt | 2 +- .../extras/logger/Logger.js.kt | 2 +- .../plugin_interfaces/core/Initializer.jvm.kt | 2 +- .../core/browser_apis/BrowserPolyfills.jvm.kt | 2 +- .../extras/logger/Logger.jvm.kt | 2 +- 491 files changed, 3175 insertions(+), 3175 deletions(-) rename composeApp/src/androidJvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/newpipe/NewPipeDownloader.kt (98%) rename composeApp/src/androidJvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/newpipe/NewPipeService.androidJvm.kt (96%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/MainActivity.kt (88%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/MyApplication.kt (92%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/Platform.android.kt (97%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayer.android.kt (99%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/di/Modules.android.kt (73%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/discovery/LocalNetworkPermission.android.kt (97%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/i18n/AppLocale.android.kt (97%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/paths/Paths.android.kt (97%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/share/AndroidShareService.kt (92%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/webview/PlatformWebviewConfig.android.kt (94%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/ZiplineDispatcher.android.kt (96%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/media/MediaBrowseHelper.kt (95%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/media/PlaybackService.kt (96%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt (99%) rename composeApp/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt (97%) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModek.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/App.kt (83%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/Platform.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/DebugConfig.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayer.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayerModels.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayerQueue.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayerQueueRepository.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/db/Database.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/deeplink/ExternalUriHandler.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/di/EnrichKoin.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/di/Init.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/di/Modules.kt (60%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/discord/DiscordRpcService.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/discovery/DeviceDiscoveryService.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/discovery/LocalNetworkPermission.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/extras/CaseConverterExt.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/i18n/AppLocale.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/jam/JamProtocol.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/jam/JamRoomClient.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/jam/JamRoomCode.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/jam/JamRoomService.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/navigation/NavTransitions.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/navigation/NavigationCommands.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/navigation/NavigationModule.kt (82%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/navigation/NavigationState.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/newpipe/NewPipeModels.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/newpipe/NewPipeService.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/paths/Paths.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/playback/CollectionPlaybackHelper.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/remote/ConnectionRequestDialog.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/remote/RemoteControlClient.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/remote/RemoteControlHandler.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/remote/RemoteControlProtocol.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/remote/RemoteControlService.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/remote/RemotePlaybackController.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/scrobble/ScrobbleManager.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/AlternativeTracksRepository.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/CacheManager.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/LocalServer.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/MatchTracksRepository.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/StreamProxy.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/StreamingUrlRepository.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/share/ShareService.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/tools/user_agents/UserAgents.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/AlbumCard.kt (82%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/ApplicationMainBar.kt (89%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/ArtistCard.kt (84%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/CollectionDetails.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/CollectionView.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/ErrorDisplay.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/FeaturedCarousel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/LikedTracksCard.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/LoginRequiredDisplay.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/PlaylistCard.kt (81%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/SharedTransitionLocals.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/TrackCard.kt (87%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/TrackList.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/TrackOptions.kt (92%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/UserCard.kt (89%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/VerticalScrollbar.kt (92%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/cards/AvatarCard.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/cards/AvatarListTile.kt (82%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/cards/MediaCardSurface.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/cards/MediaEmptyState.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/cards/PlayableCard.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/cards/PlayableListTile.kt (89%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/coverflow/Coverflow.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/coverflow/CoverflowGeometry.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/coverflow/CoverflowItem.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/coverflow/CoverflowParams.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/coverflow/CoverflowScope.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/coverflow/CoverflowState.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/coverflow/Mirror.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/misc/Shimmer.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/theming/AppTheme.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/webview/PlatformWebView.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/webview/PlatformWebviewConfig.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/webview/PlatformWebviewScreen.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/BuiltInPluginService.kt (81%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/FileSystemHTTPClient.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/PluginService.kt (69%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/ZiplineDispatcher.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/ZiplinePluginService.kt (79%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/host_apis/RealCryptoAPI.kt (89%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/host_apis/RealHttpClientAPI.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt (92%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt (88%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/host_apis/RealWebViewAPI.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/host_apis/additionals/DES.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt (88%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/album/AlbumRepository.kt (87%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/album/AlbumScreen.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/album/AlbumViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/artist/ArtistRepository.kt (86%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/artist/ArtistScreen.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/artist/ArtistViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/blacklist/BlacklistRepository.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/blacklist/BlacklistScreen.kt (93%) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/blacklist/BlacklistViewModel.kt (89%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/devices/DevicesScreen.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/devices/DevicesViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/devices/PlayDestinationPicker.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/devices/RemoteControlScreen.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/devices/RemoteControlViewModel.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/downloads/DownloadManager.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/downloads/DownloadProgressIcon.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/downloads/DownloadsScreen.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/downloads/DownloadsViewModel.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/home/HomeScreen.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/home/HomeScreenRepository.kt (88%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/home/HomeScreenViewModel.kt (92%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/jam/JamScreen.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/jam/JamViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/jam/PlayDestinationPicker.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/LibraryMediaCollection.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/LibraryRepository.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/LibraryScreen.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/LibraryState.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/LibraryTab.kt (82%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/album/LibraryAlbumsScreen.kt (86%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/album/LibraryAlbumsViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/artist/LibraryArtistsScreen.kt (86%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/artist/LibraryArtistsViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt (89%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaModels.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/playlist/AddToPlaylistPicker.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt (87%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/playlist/PlaylistFormSheet.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/lyrics/LyricsScreen.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/lyrics/LyricsViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/lyrics/SyncedLyricsList.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/playlist/AddTracksToPlaylistDialog.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/playlist/PlaylistRepository.kt (87%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/playlist/PlaylistScreen.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/playlist/PlaylistViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/BuiltInPlugins.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/GitHubPluginRepo.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/MetadataAuthViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/PluginManager.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/PluginModels.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/PluginScreen.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/PluginViewModel.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/components/PluginCard.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/components/PluginDialogParts.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/components/PluginInstallDialog.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/components/PluginLogo.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/components/PluginPermissionsDialog.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/guard/MetadataPluginGuard.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt (85%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/saved_tracks/SavedTracksRepository.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/saved_tracks/SavedTracksScreen.kt (88%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/saved_tracks/SavedTracksViewModel.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/search/SearchRepository.kt (88%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/search/SearchScreen.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/search/SearchScreenViewModel.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/JamSettingsScreen.kt (92%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/JamSettingsViewModel.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/LocalMediaFoldersViewModel.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/SettingsConstants.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/SettingsModels.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/SettingsRepository.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/SettingsScreen.kt (84%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/SettingsSidebarLayout.kt (87%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/SettingsViewModel.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/components/InteractiveSettingCards.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/components/LocalMediaFoldersEditor.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/components/SettingsCardItem.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/AppearanceSection.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/CacheSection.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/DesktopSection.kt (86%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/DownloadsSection.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/JamSection.kt (81%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/LanguageRegionSection.kt (88%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/PlaybackSection.kt (89%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/PluginsSection.kt (85%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/SupportSection.kt (84%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/UpdatesSection.kt (85%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/sections/utils.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppBottombar.kt (86%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppExpandedPlayer.kt (93%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppFloatingPlayer.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppLargePlayer.kt (92%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppPlayerState.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppShell.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppShellViewModel.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/AppSidebar.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/PlayerArtistsText.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/PlayerHeartButton.kt (84%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/PlayerOptionDialogs.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/PlayerOptionsViewModel.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/PlayerTimeFormat.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt (87%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/player_queue/PlayerQueueContent.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/shell/player_queue/QueueSheet.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/support/Support.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/update/AppUpdateDialog.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/update/AppUpdateService.kt (95%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/update/PluginUpdateDialog.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/update/PluginUpdateService.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/webview/WebViewScreen.kt (82%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/WelcomeContent.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/WelcomeModels.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/WelcomeRepository.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/WelcomeScreen.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/WelcomeViewModel.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/WelcomeVisuals.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/pages/BrandingPage.kt (91%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/pages/DonationPage.kt (94%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/pages/HowItWorksPage.kt (90%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/welcome/pages/PluginsPage.kt (66%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/ArrowLeft3.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/BrandBuyMeACoffee.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/BrandOpencollective.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/BrandPatreon.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/CarbonGithubLogo.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/CustomServer.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/FluentDismiss.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/FluentMaximize.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/FluentMinus.kt (96%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/FluentSquareMultiple.kt (98%) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/Iconsax3DotsMore.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxAdd.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxAddSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxArrowDown4.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxArrowSquareUp.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxBox.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxBoxAdd.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxCd.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxCheckCircle.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxCheckSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxCloseSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxColorSwatch.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxColorsSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxDirectboxReceive.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxDocumentDownload.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxDocumentText.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxDragHandle.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxEdit.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxExportArrowBulk.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxEye.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxFilterSearch.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxFolder.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxFolderAdd.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxFolderOpen.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxForbidden.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxFormatCircle.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxGlobe.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxHeart.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxHeart2.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxHome.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxHomeBroken.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxImportArrow2.kt (97%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxInformation.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxLanguageSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxLink.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxLocation.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMagic.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMinusSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMirroringScreen.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMoon.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusic.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicCircle.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicDashboard.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicFilter.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicLibrary.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicPlay.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicPlaylist.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxNext.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxPause.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxPauseCircle.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxPlay.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxPlayCircle.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxPlayCircle2.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxPrevious.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxRefreshArrow2.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxRefreshRight.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxRepeatArrow.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxRepeatMusic.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxRepeateMusic.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxRepeateOne.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSearch.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSearchBroken.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSetting.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSetting2.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSettingTwotone.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxShare.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxShuffle.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSort.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSound.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSoundTwotone.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxStopCircle.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxTag.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxTextalignLeft.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxTrash.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxUserRemove.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxVerticalSetting.kt (99%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxVolumeCross.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxVolumeHigh.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxVolumeLow.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/IconsaxWifiSquare.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/InconsaxClock.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/LineDiscord.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/Play.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/SwapHorizontal2.kt (98%) rename composeApp/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/spotube/resources/iconsax/User.kt (98%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayerQueueTest.kt (98%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt (96%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/FakeAudioPlayer.kt (99%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt (91%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/FakeBlacklistRepository.kt (76%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/FakePluginProvider.kt (76%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/FakeSettingsProvider.kt (79%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/scrobble/ScrobbleManagerTest.kt (92%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt (97%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/StreamingUrlRepositoryFakes.kt (87%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/server/StreamingUrlRepositoryTest.kt (97%) rename composeApp/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/host_apis/additionals/DESTest.kt (95%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/MainViewController.kt (96%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/Platform.ios.kt (97%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayer.ios.kt (99%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/di/Modules.ios.kt (66%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/discovery/LocalNetworkPermission.ios.kt (94%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/i18n/AppLocale.ios.kt (97%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/newpipe/NewPipeService.ios.kt (96%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/paths/Paths.ios.kt (98%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/share/IosShareService.kt (93%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/webview/PlatformWebviewConfig.ios.kt (95%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/ZiplineDispatcher.ios.kt (96%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt (94%) rename composeApp/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt (93%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/Platform.jvm.kt (98%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayer.jvm.kt (99%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/di/Modules.jvm.kt (67%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/discovery/LocalNetworkPermission.jvm.kt (94%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/i18n/AppLocale.jvm.kt (97%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/mediacontrol/SystemMediaControlService.kt (95%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/paths/Paths.jvm.kt (97%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/share/JvmShareService.kt (91%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/systemtray/SystemTray.kt (96%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/systemtray/SystemTrayService.kt (91%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/ApplicationMainBar.jvm.kt (92%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/LocalApplication.kt (95%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/LocalWindow.kt (96%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/VerticalScrollbar.jvm.kt (95%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/webview/PlatformWebviewConfig.jvm.kt (92%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/zipline/ZiplineDispatcher.jvm.kt (96%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/main.kt (83%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt (99%) rename composeApp/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt (93%) rename composeApp/src/jvmTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/audioplayer/AudioPlayerTest.kt (99%) rename composeApp/src/jvmTest/kotlin/{dev/krtirtho => org/spotube}/spotube/core/i18n/AppLocaleProviderTest.kt (97%) rename composeApp/src/jvmTest/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/jam/JamLayoutTest.kt (91%) rename composeApp/src/jvmTest/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/LibraryToolbarTest.kt (97%) rename composeApp/src/jvmTest/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/library/MediaLibraryUiTest.kt (92%) rename composeApp/src/jvmTest/kotlin/{dev/krtirtho => org/spotube}/spotube/modules/settings/LocalMediaFoldersEditorTest.kt (97%) rename composeApp/src/mobileMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/ApplicationMainBar.mobile.kt (97%) rename composeApp/src/mobileMain/kotlin/{dev/krtirtho => org/spotube}/spotube/core/ui/component/VerticalScrollbar.mobile.kt (95%) delete mode 100644 js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/js.kt create mode 100644 js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt (89%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/core/RealCoreAPI.kt (88%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt (87%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt (95%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt (80%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt (76%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt (76%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt (87%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt (83%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt (81%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt (80%) rename js_plugin_example/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt (80%) rename plugin_interfaces/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/Initializer.android.kt (94%) rename plugin_interfaces/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt (93%) rename plugin_interfaces/src/androidMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/logger/Logger.android.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/Initializer.kt (89%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt (89%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/logger/Logger.kt (94%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/HeadersBuilder.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/Interceptor.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/Serializers.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/SpotrClient.kt (97%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/SpotrClientConfig.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt (97%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/spotor/URLBuilder.kt (97%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/host_apis/CryptoAPI.kt (99%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/host_apis/HttpClientAPI.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/host_apis/PersistedStorageAPI.kt (95%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/host_apis/SystemInformationAPI.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/host_apis/WebViewAPI.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/audio/AudioAPI.kt (88%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/audio/AudioModels.kt (96%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/core/CoreAPI.kt (95%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt (94%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt (87%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt (94%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt (82%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt (90%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt (79%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt (80%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt (84%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt (78%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt (95%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt (86%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt (82%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt (88%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt (77%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt (85%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt (79%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt (93%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt (87%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt (93%) rename plugin_interfaces/src/commonMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt (94%) rename plugin_interfaces/src/commonTest/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt (93%) rename plugin_interfaces/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/Initializer.ios.kt (94%) rename plugin_interfaces/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt (93%) rename plugin_interfaces/src/iosMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/logger/Logger.ios.kt (96%) rename plugin_interfaces/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interface/host_apis/.gitkeep (100%) rename plugin_interfaces/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/Initializer.js.kt (91%) rename plugin_interfaces/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt (93%) rename plugin_interfaces/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt (98%) rename plugin_interfaces/src/jsMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/logger/Logger.js.kt (96%) rename plugin_interfaces/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/Initializer.jvm.kt (94%) rename plugin_interfaces/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt (93%) rename plugin_interfaces/src/jvmMain/kotlin/{dev/krtirtho => org/spotube}/plugin_interfaces/extras/logger/Logger.jvm.kt (96%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4b62e4f8..8a31ee59 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,7 +76,7 @@ body: description: Where did you install Spotube from? multiple: true options: - - "Website (spotube.cc)" + - "Website (spotube.org)" - "GitHub Releases (Binary)" - "GitHub Actions (Nightly Binary)" - "Play Store (Android)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 77189e7d..493ffc8f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Ask a question - url: https://github.com/KRTirtho/spotube/discussions/new?category=q-a + url: https://github.com/team-spotube/spotube/discussions/new?category=q-a about: The place for questions, support and feature requests diff --git a/AGENTS.md b/AGENTS.md index 25f345a7..ba88dadf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,9 @@ - `:composeApp` uses custom KMP source sets (`mobileMain`, `androidJvmMain`) with explicit `dependsOn`; Gradle prints "Default Kotlin Hierarchy Template Not Applied Correctly" warning. Treat as known/expected. ## Real app entrypoints -- Desktop/JVM: `composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt` (`mainClass = dev.krtirtho.spotube.MainKt`). -- Android: `composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt`. -- iOS bridge: `composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. +- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`). +- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`. +- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. ## High-value commands - Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. @@ -24,7 +24,7 @@ ## Codegen and plugin packaging - JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`. -- Zipline plugin entrypoint: `mainFunction = "dev.krtirtho.js_plugin_example.main"` in `js_plugin_example/build.gradle.kts`. Plugin metadata from `js_plugin_example/plugin.json`. +- Zipline plugin entrypoint: `mainFunction = "org.spotube.js_plugin_example.main"` in `js_plugin_example/build.gradle.kts`. Plugin metadata from `js_plugin_example/plugin.json`. ## Plugin architecture - `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cf335a..a3b42bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [5.1.2](https://github.com/KRTirtho/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) +## [5.1.2](https://github.com/team-spotube/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) ### Bug Fixes @@ -8,14 +8,14 @@ - Dismiss search dropdown and keyboard on submission - Custom image helper null exception -## [5.1.1](https://github.com/KRTirtho/spotube/compare/v5.1.0...v5.1.1) (2026-02-24) +## [5.1.1](https://github.com/team-spotube/spotube/compare/v5.1.0...v5.1.1) (2026-02-24) ### Bug Fixes - Upgrade NewPipeExtractor to latest version fixing playback issue - Lyrics not working -## [5.1.0](https://github.com/KRTirtho/spotube/compare/v5.0.0...v5.1.0) (2025-11-14) +## [5.1.0](https://github.com/team-spotube/spotube/compare/v5.0.0...v5.1.0) (2025-11-14) ### Features @@ -30,7 +30,7 @@ - Add NewPipe support for desktop platforms - Add default plugin loading capability - **queue**: Add multi-select and bulk actions to queue - ([#2839](https://github.com/KRTirtho/spotube/issues/2839)) + ([#2839](https://github.com/team-spotube/spotube/issues/2839)) - **android**: Add 16KB page size support ### Bug Fixes @@ -41,14 +41,14 @@ - Download not working in different devices and slow - **playback**: Use stream instead of chunked serving of audio bytes -## [5.0.0](https://github.com/KRTirtho/spotube/compare/v4.0.2...v5.0.0) (2025-09-08) +## [5.0.0](https://github.com/team-spotube/spotube/compare/v4.0.2...v5.0.0) (2025-09-08) ### Features - Add ISRC track search for YouTube - ([#2594](https://github.com/KRTirtho/spotube/issues/2594)) + ([#2594](https://github.com/team-spotube/spotube/issues/2594)) - Add new icons #2676 by @alexio-dev - ([#2678](https://github.com/KRTirtho/spotube/issues/2678)) + ([#2678](https://github.com/team-spotube/spotube/issues/2678)) - Add connect confirmation dialog - Add metadata api service and models - **metadata-plugin**: Add pagination support, feed and playlist CRUD endpoints @@ -105,19 +105,19 @@ ### Translation - Add Traditional Chinese translation - ([#2762](https://github.com/KRTirtho/spotube/issues/2762)) + ([#2762](https://github.com/team-spotube/spotube/issues/2762)) - Fix Japanese translations - ([#2732](https://github.com/KRTirtho/spotube/issues/2732)) + ([#2732](https://github.com/team-spotube/spotube/issues/2732)) - Correction of the dutch language - ([#1306](https://github.com/KRTirtho/spotube/issues/1306)) + ([#1306](https://github.com/team-spotube/spotube/issues/1306)) -## [4.0.2](https://github.com/krtirtho/spotube/compare/v4.0.1...v4.0.2) (2025-03-16) +## [4.0.2](https://github.com/team-spotube/spotube/compare/v4.0.1...v4.0.2) (2025-03-16) ### Bug Fixes - invalid access token exception #2525 -## [4.0.1](https://github.com/krtirtho/spotube/compare/v4.0.0...v4.0.1) (2025-03-15) +## [4.0.1](https://github.com/team-spotube/spotube/compare/v4.0.0...v4.0.1) (2025-03-15) ### Bug Fixes @@ -138,7 +138,7 @@ - **translation**: add tagalog language support #2504 - **translation**: add tamil translation for spotube #2501 -## [4.0.0](https://github.com/krtirtho/spotube/compare/v3.9.0...v4.0.0) (2025-03-07) +## [4.0.0](https://github.com/team-spotube/spotube/compare/v3.9.0...v4.0.0) (2025-03-07) ## Changes @@ -169,7 +169,7 @@ - implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) - custom piped & invidious instance support -## [3.9.0](https://github.com/krtirtho/spotube/compare/v3.8.3...v3.9.0) (2024-12-08) +## [3.9.0](https://github.com/team-spotube/spotube/compare/v3.8.3...v3.9.0) (2024-12-08) ## Changes @@ -186,7 +186,7 @@ - add invidious audio source and fix auto skipping tracks (#2005) - track caching and cached track export support (#2117) -## [3.8.3](https://github.com/krtirtho/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) +## [3.8.3](https://github.com/team-spotube/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) ## Changes @@ -198,7 +198,7 @@ - **macos**: enable same window webview support -## [3.8.2](https://github.com/krtirtho/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) +## [3.8.2](https://github.com/team-spotube/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) ## Changes @@ -206,7 +206,7 @@ - endless song loading issue and no playback #1925 -## [3.8.1](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) +## [3.8.1](https://github.com/team-spotube/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) ## Changes @@ -234,7 +234,7 @@ - **desktop**: show error dialog if webview is not found on login #1871 - manually detect and define touch behavior #1763 -## [3.8.0](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) +## [3.8.0](https://github.com/team-spotube/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) ### Features @@ -268,1388 +268,1388 @@ - popup menu item opacity - linux: change app id in flatpak environment -## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) +## [3.7.1](https://github.com/team-spotube/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) ### Bug Fixes - alternative sources not showing up for SongLink matched results - ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) + ([37d002d](https://github.com/team-spotube/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) - **android:** Media Controls not working above Android 14 - [#1561](https://github.com/krtirtho/spotube/issues/1561) - ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) + [#1561](https://github.com/team-spotube/spotube/issues/1561) + ([3394c1b](https://github.com/team-spotube/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) - browse anonymously button takes to wrong route - ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) + ([73c5b30](https://github.com/team-spotube/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) - **desktop:** titlebar drag to move not working - ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) + ([5f280a1](https://github.com/team-spotube/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) - **desktop:** window is not centered - ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) + ([47f98b9](https://github.com/team-spotube/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) - **ios:** download not working - [#1575](https://github.com/krtirtho/spotube/issues/1575) - ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) + [#1575](https://github.com/team-spotube/spotube/issues/1575) + ([6591ec0](https://github.com/team-spotube/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) - **linux:** application window not visible after launch - ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) + ([8fc44ed](https://github.com/team-spotube/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) - local track not showing up in queue - ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) + ([d82261c](https://github.com/team-spotube/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) - use weak match for Jiosaavn fallback to improve matching - ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) + ([6cb2986](https://github.com/team-spotube/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) - **windows:** media controls not showing up - [#1542](https://github.com/krtirtho/spotube/issues/1542) - ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) + [#1542](https://github.com/team-spotube/spotube/issues/1542) + ([d7d864f](https://github.com/team-spotube/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) - **windows:** revert Flutter version to 3.19.6 to avoid distortion - [#1553](https://github.com/krtirtho/spotube/issues/1553) - ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) + [#1553](https://github.com/team-spotube/spotube/issues/1553) + ([982cf0b](https://github.com/team-spotube/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) -## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) +## [3.7.0](https://github.com/team-spotube/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) ### Features - local library folder cards - ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) -- Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) - ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) + ([fc5bfa0](https://github.com/team-spotube/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) +- Local music library ([#1479](https://github.com/team-spotube/spotube/issues/1479)) + ([22caa81](https://github.com/team-spotube/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) - personalized stats based on local music history - ([#1522](https://github.com/krtirtho/spotube/issues/1522)) - ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) + ([#1522](https://github.com/team-spotube/spotube/issues/1522)) + ([82307bc](https://github.com/team-spotube/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) - play initially available tracks of playlist/album immediately and fetch rest - in background [#670](https://github.com/krtirtho/spotube/issues/670) - ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) + in background [#670](https://github.com/team-spotube/spotube/issues/670) + ([02acbd9](https://github.com/team-spotube/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) - **player:** add volume slider floating label showing percentage - ([#1445](https://github.com/krtirtho/spotube/issues/1445)) - ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1445](https://github.com/team-spotube/spotube/issues/1445)) + ([8fad225](https://github.com/team-spotube/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** add Basque translation - ([#1493](https://github.com/krtirtho/spotube/issues/1493)) - ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) + ([#1493](https://github.com/team-spotube/spotube/issues/1493)) + ([dbc1c45](https://github.com/team-spotube/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) - **translations:** add Finnish translations - ([#1449](https://github.com/krtirtho/spotube/issues/1449)) - ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1449](https://github.com/team-spotube/spotube/issues/1449)) + ([edc997e](https://github.com/team-spotube/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** add georgian language - ([#1450](https://github.com/krtirtho/spotube/issues/1450)) - ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) + ([#1450](https://github.com/team-spotube/spotube/issues/1450)) + ([1e7f0e1](https://github.com/team-spotube/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) - **translations:** add Indonesian translation - ([#1426](https://github.com/krtirtho/spotube/issues/1426)) - ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1426](https://github.com/team-spotube/spotube/issues/1426)) + ([0280654](https://github.com/team-spotube/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** Improve tr locales - ([#1419](https://github.com/krtirtho/spotube/issues/1419)) - ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1419](https://github.com/team-spotube/spotube/issues/1419)) + ([bf45681](https://github.com/team-spotube/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - upgrade to Flutter 3.22.0 - ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) + ([71341ec](https://github.com/team-spotube/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) ### Bug Fixes - fallback to LRCLIB when lyrics line less than 6 lines - [#1461](https://github.com/krtirtho/spotube/issues/1461) - ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) + [#1461](https://github.com/team-spotube/spotube/issues/1461) + ([9aea354](https://github.com/team-spotube/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) - **linux:** tray icon not showing - [#541](https://github.com/krtirtho/spotube/issues/541) - ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) + [#541](https://github.com/team-spotube/spotube/issues/541) + ([7ac7917](https://github.com/team-spotube/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) - local track not showing up in queue - ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) + ([d82261c](https://github.com/team-spotube/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) - **macos:** Logs directory not created by default - [#1353](https://github.com/krtirtho/spotube/issues/1353) - ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) + [#1353](https://github.com/team-spotube/spotube/issues/1353) + ([4ca8939](https://github.com/team-spotube/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) - **playback:** skipping tracks with unplayable sources instead of falling back - [#1492](https://github.com/krtirtho/spotube/issues/1492) - ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) + [#1492](https://github.com/team-spotube/spotube/issues/1492) + ([c607a33](https://github.com/team-spotube/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) - **search:** load more button not working - [#1417](https://github.com/krtirtho/spotube/issues/1417) - ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) + [#1417](https://github.com/team-spotube/spotube/issues/1417) + ([7e07c2e](https://github.com/team-spotube/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) - some text are garbled in different parts of the app - [#1463](https://github.com/krtirtho/spotube/issues/1463) - [#1505](https://github.com/krtirtho/spotube/issues/1505) - ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) + [#1463](https://github.com/team-spotube/spotube/issues/1463) + [#1505](https://github.com/team-spotube/spotube/issues/1505) + ([d2683c5](https://github.com/team-spotube/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) - spotify friends and user profile icon (mobile) showing when not authenticated - [#1410](https://github.com/krtirtho/spotube/issues/1410) - ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) + [#1410](https://github.com/team-spotube/spotube/issues/1410) + ([9bccbc9](https://github.com/team-spotube/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) - **updater:** dead link - ([#1408](https://github.com/krtirtho/spotube/issues/1408)) - ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1408](https://github.com/team-spotube/spotube/issues/1408)) + ([6907f9c](https://github.com/team-spotube/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - windows SSL Certificate error breaking login - [#905](https://github.com/krtirtho/spotube/issues/905) - ([#1474](https://github.com/krtirtho/spotube/issues/1474)) - ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), - closes [#1468](https://github.com/krtirtho/spotube/issues/1468) + [#905](https://github.com/team-spotube/spotube/issues/905) + ([#1474](https://github.com/team-spotube/spotube/issues/1474)) + ([937a706](https://github.com/team-spotube/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), + closes [#1468](https://github.com/team-spotube/spotube/issues/1468) - **windows:** installer tries to install in current directory - ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) + ([c3c9fc5](https://github.com/team-spotube/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) -## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) +## [3.6.0](https://github.com/team-spotube/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) ### Features - add Spotify homepage personalized recommendations - ([#1402](https://github.com/krtirtho/spotube/issues/1402)) - ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) + ([#1402](https://github.com/team-spotube/spotube/issues/1402)) + ([9e25c74](https://github.com/team-spotube/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) - add user profile page - ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) + ([39e97ee](https://github.com/team-spotube/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) - **android:** Filter Device To Force High Frame Rate - ([#880](https://github.com/krtirtho/spotube/issues/880)) - ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) + ([#880](https://github.com/team-spotube/spotube/issues/880)) + ([6e41b10](https://github.com/team-spotube/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) - improved caching based on riverpod - ([#1343](https://github.com/krtirtho/spotube/issues/1343)) - ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) + ([#1343](https://github.com/team-spotube/spotube/issues/1343)) + ([6673e5a](https://github.com/team-spotube/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) - LAN connect a.k.a control remote Spotube playback and local output device - selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) - ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) + selection ([#1355](https://github.com/team-spotube/spotube/issues/1355)) + ([68374ef](https://github.com/team-spotube/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) - **lyrics:** add LRCLIB lyrics provider as fallback - ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) + ([5afe823](https://github.com/team-spotube/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) - search history support - [#1236](https://github.com/krtirtho/spotube/issues/1236) - ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) + [#1236](https://github.com/team-spotube/spotube/issues/1236) + ([82b1cfa](https://github.com/team-spotube/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) - **translations:** Add Czech translation - ([#1401](https://github.com/krtirtho/spotube/issues/1401)) - ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) + ([#1401](https://github.com/team-spotube/spotube/issues/1401)) + ([5a6b800](https://github.com/team-spotube/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) - **translations:** add Thai Language - ([#1319](https://github.com/krtirtho/spotube/issues/1319)) - ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) + ([#1319](https://github.com/team-spotube/spotube/issues/1319)) + ([b70f250](https://github.com/team-spotube/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) ### Bug Fixes - instance of Artist bug - [#1362](https://github.com/krtirtho/spotube/issues/1362) - ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) + [#1362](https://github.com/team-spotube/spotube/issues/1362) + ([c8dd802](https://github.com/team-spotube/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) - **playback:** sponsor block skips and stutters in same position - ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) + ([0d080b7](https://github.com/team-spotube/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) -## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) +## [3.5.0](https://github.com/team-spotube/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) ### Features - add endless playback support - [#285](https://github.com/krtirtho/spotube/issues/285) - ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) + [#285](https://github.com/team-spotube/spotube/issues/285) + ([9dfd49c](https://github.com/team-spotube/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) - add getting started page - ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) + ([96a2a1f](https://github.com/team-spotube/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) - Add iOS background play support - ([#1166](https://github.com/krtirtho/spotube/issues/1166)) - ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) + ([#1166](https://github.com/team-spotube/spotube/issues/1166)) + ([095587e](https://github.com/team-spotube/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) - add songlink based track matching for youtube and open song link button - ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) + ([9095a8c](https://github.com/team-spotube/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) - **playlist:** show confirmation before deleting user playlist - [#1222](https://github.com/krtirtho/spotube/issues/1222) - ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) -- Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) - ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) + [#1222](https://github.com/team-spotube/spotube/issues/1222) + ([9f92440](https://github.com/team-spotube/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) +- Sort by Duration ([#1238](https://github.com/team-spotube/spotube/issues/1238)) + ([6f8271f](https://github.com/team-spotube/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) - start radio support - ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) + ([4defeef](https://github.com/team-spotube/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) - **translations:** add Korean translation - ([#1275](https://github.com/krtirtho/spotube/issues/1275)) - ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) + ([#1275](https://github.com/team-spotube/spotube/issues/1275)) + ([fdea930](https://github.com/team-spotube/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) - **translations:** Added Vietnamese - ([#1135](https://github.com/krtirtho/spotube/issues/1135)) - ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) + ([#1135](https://github.com/team-spotube/spotube/issues/1135)) + ([019ba86](https://github.com/team-spotube/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) - **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing - ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) + ([ba69496](https://github.com/team-spotube/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) ### Bug Fixes - album images are small in certain places - ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) + ([ca76a39](https://github.com/team-spotube/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) - album, artist page not loading - [#1282](https://github.com/krtirtho/spotube/issues/1282) - ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) + [#1282](https://github.com/team-spotube/spotube/issues/1282) + ([a9a1d4c](https://github.com/team-spotube/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) - **android:** audio issue when screen is off and broadcast audio session id - ([#1221](https://github.com/krtirtho/spotube/issues/1221) & - [#1247](https://github.com/krtirtho/spotube/issues/1247)) - ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), - closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) + ([#1221](https://github.com/team-spotube/spotube/issues/1221) & + [#1247](https://github.com/team-spotube/spotube/issues/1247)) + ([17105a6](https://github.com/team-spotube/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), + closes [KRTirtho/spotube#571](https://github.com/team-spotube/spotube/issues/571) - **android:** only ask battery optimization once - [#1252](https://github.com/krtirtho/spotube/issues/1252) - ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) + [#1252](https://github.com/team-spotube/spotube/issues/1252) + ([e516afb](https://github.com/team-spotube/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) - **android:** pressing back button in any other tab other than home exits the app - ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) + ([c3289a0](https://github.com/team-spotube/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) - **android:** system back button in player page exits the app - ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) + ([3294f65](https://github.com/team-spotube/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) - cleanTitle removing feat and ft from words instead of whole words - ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) + ([8612345](https://github.com/team-spotube/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) - friends list not scrollable with mouse drag - ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) + ([ab08c82](https://github.com/team-spotube/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) - no draggable scrollbar in playlist/album page - [#1158](https://github.com/krtirtho/spotube/issues/1158) - ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) + [#1158](https://github.com/team-spotube/spotube/issues/1158) + ([6f71e52](https://github.com/team-spotube/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) - non-banger songs breaking the queue if sources not found - ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) + ([90f7c53](https://github.com/team-spotube/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) - track loading when not found in Youtube - ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) + ([e964f61](https://github.com/team-spotube/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) - **translations:** Update app_nl.arb - ([#1168](https://github.com/krtirtho/spotube/issues/1168)) - ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) + ([#1168](https://github.com/team-spotube/spotube/issues/1168)) + ([8167963](https://github.com/team-spotube/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) -## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) +## [3.4.1](https://personal.github.com/team-spotube/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) ### Features - add create playlist button in add playlist dialog - ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) + ([2168a64](https://personal.github.com/team-spotube/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) - add spotify friends activity - ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) - ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) + ([#1130](https://personal.github.com/team-spotube/spotube/issues/1130)) + ([7983932](https://personal.github.com/team-spotube/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) - **deep-link:** add track opening page - ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) + ([988a975](https://personal.github.com/team-spotube/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) - haptic feedback on long press and reordering actions - ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) + ([6242200](https://personal.github.com/team-spotube/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) - improve youtube/piped matching by suffixing "- Topic" - ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) + ([8184555](https://personal.github.com/team-spotube/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) - **translations:** add Nepali (नेपाली) translations - ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) - ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), - closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) - [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) + ([#1111](https://personal.github.com/team-spotube/spotube/issues/1111)) + ([c3ebf56](https://personal.github.com/team-spotube/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), + closes [#1074](https://personal.github.com/team-spotube/spotube/issues/1074) + [#1100](https://personal.github.com/team-spotube/spotube/issues/1100) ### Bug Fixes - alternative searched sources doesn't play - [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) - ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) + [#1059](https://personal.github.com/team-spotube/spotube/issues/1059) + ([a8e9b82](https://personal.github.com/team-spotube/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) - alternative source doesn't persist on next restart - [#840](https://personal.github.com/krtirtho/spotube/issues/840) - ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) + [#840](https://personal.github.com/team-spotube/spotube/issues/840) + ([62fde50](https://personal.github.com/team-spotube/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) - **android:** download failing for permission issues - [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) - ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) + [#1015](https://personal.github.com/team-spotube/spotube/issues/1015) + ([5509cae](https://personal.github.com/team-spotube/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) - artist page error - [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) - ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) + [#1018](https://personal.github.com/team-spotube/spotube/issues/1018) + ([8cd650b](https://personal.github.com/team-spotube/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) - audio resumes after a phone call even if it was paused before - [#926](https://personal.github.com/krtirtho/spotube/issues/926) - ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) + [#926](https://personal.github.com/team-spotube/spotube/issues/926) + ([fd1899f](https://personal.github.com/team-spotube/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) - better error message for failing to find lyrics - [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) - ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) + [#1085](https://personal.github.com/team-spotube/spotube/issues/1085) + ([e58e18d](https://personal.github.com/team-spotube/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) - Black window flash when starting the app - ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) - ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) + ([#1003](https://personal.github.com/team-spotube/spotube/issues/1003)) + ([02e44fc](https://personal.github.com/team-spotube/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) - **linux:** crash after login - ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) + ([0dfd401](https://personal.github.com/team-spotube/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) - **macos:** backbutton and window button overlap and unused empty space on home - ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) + ([b9417ca](https://personal.github.com/team-spotube/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) - **macos:** download folder unchangeable - ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) + ([9d74cf5](https://personal.github.com/team-spotube/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) - **macos:** Respect Minimize to tray option - ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) - ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) + ([#1001](https://personal.github.com/team-spotube/spotube/issues/1001)) + ([69559ba](https://personal.github.com/team-spotube/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) - **macos:** system tray shows name and sidebar weird gap - [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) - ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) + [#1083](https://personal.github.com/team-spotube/spotube/issues/1083) + ([27057ea](https://personal.github.com/team-spotube/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) - releases section is empty when user doesn't follow any artists - [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) - ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) + [#1104](https://personal.github.com/team-spotube/spotube/issues/1104) + ([682e88e](https://personal.github.com/team-spotube/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) - search page vertical scrollbar moves on horizontal scroll - [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) - ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) + [#1017](https://personal.github.com/team-spotube/spotube/issues/1017) + ([c203ac6](https://personal.github.com/team-spotube/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) - songs doesn't play when sources with preferred audio codec is empty - ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) - ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) + ([#976](https://personal.github.com/team-spotube/spotube/issues/976)) + ([ba4e11a](https://personal.github.com/team-spotube/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) - track index not showing after 200 - ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) + ([a752cf4](https://personal.github.com/team-spotube/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) - track pad horizontal scrolling not working - ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) + ([59e0e6b](https://personal.github.com/team-spotube/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) -## [3.4.0](https://github.com/KRTirtho/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) +## [3.4.0](https://github.com/team-spotube/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) ### Features - Add Go to Album option in track option - [#917](https://github.com/KRTirtho/spotube/issues/917) - ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) + [#917](https://github.com/team-spotube/spotube/issues/917) + ([b0beeca](https://github.com/team-spotube/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) - **translations:** add Italian language translations - ([#818](https://github.com/KRTirtho/spotube/issues/818)) - ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), - closes [#676](https://github.com/KRTirtho/spotube/issues/676) - [#676](https://github.com/KRTirtho/spotube/issues/676) + ([#818](https://github.com/team-spotube/spotube/issues/818)) + ([e4eb0e2](https://github.com/team-spotube/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), + closes [#676](https://github.com/team-spotube/spotube/issues/676) + [#676](https://github.com/team-spotube/spotube/issues/676) - compact genre view in home page - ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) -- Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) - ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) + ([82ed5e9](https://github.com/team-spotube/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) +- Deep link support ([#950](https://github.com/team-spotube/spotube/issues/950)) + ([4050f55](https://github.com/team-spotube/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) - improve loading animations - ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) + ([b92583d](https://github.com/team-spotube/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) - toggle for discord rpc - ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) + ([24a2294](https://github.com/team-spotube/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) - **translations:** add Dutch Language - ([#969](https://github.com/KRTirtho/spotube/issues/969)) - ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) + ([#969](https://github.com/team-spotube/spotube/issues/969)) + ([3ad7ba6](https://github.com/team-spotube/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) ### Bug Fixes - add safe area in home - ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) + ([9ee6067](https://github.com/team-spotube/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) - amoled mode and color scheme can't be changed - ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) + ([840e014](https://github.com/team-spotube/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) - doesn't minimize to tray when system title bar close button is used - [#866](https://github.com/KRTirtho/spotube/issues/866) - ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) + [#866](https://github.com/team-spotube/spotube/issues/866) + ([bb8f250](https://github.com/team-spotube/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) - genre border issues - ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) + ([2fb16e6](https://github.com/team-spotube/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) - Incorrect "Artist" label/heading on Search Results Page - [#920](https://github.com/KRTirtho/spotube/issues/920) - ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) + [#920](https://github.com/team-spotube/spotube/issues/920) + ([f86d544](https://github.com/team-spotube/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) - metadata not getting added for YouTube tracks - [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of - downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) - ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) + [#916](https://github.com/team-spotube/spotube/issues/916) and Wrong duration of + downloaded tracks [#912](https://github.com/team-spotube/spotube/issues/912) + ([a7b9398](https://github.com/team-spotube/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) - Playlist refresh not working - [#915](https://github.com/KRTirtho/spotube/issues/915) - ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) + [#915](https://github.com/team-spotube/spotube/issues/915) + ([5f1df5a](https://github.com/team-spotube/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) - track view header title overflow and player view drag glitch - ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) + ([b04d884](https://github.com/team-spotube/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) - wrong artist name sent while scrobbling - [#958](https://github.com/KRTirtho/spotube/issues/958) - ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) + [#958](https://github.com/team-spotube/spotube/issues/958) + ([dcbe729](https://github.com/team-spotube/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) -## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) +## [3.3.0](https://github.com/team-spotube/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) ### Features - Add JioSaavn as audio source - ([#881](https://github.com/KRTirtho/spotube/issues/881)) - ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) + ([#881](https://github.com/team-spotube/spotube/issues/881)) + ([14069cd](https://github.com/team-spotube/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) - **android:** better quick scroll/drag to scroll implementation - ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) + ([2e2c44f](https://github.com/team-spotube/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) - **artist:** modularize page and add wikipedia section - ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) -- discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) - ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) + ([2a69886](https://github.com/team-spotube/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) +- discord RPC integration [#98](https://github.com/team-spotube/spotube/issues/98) + ([88b8785](https://github.com/team-spotube/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) - **mini_player:** show/hide lyrics - [#851](https://github.com/KRTirtho/spotube/issues/851) - ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) + [#851](https://github.com/team-spotube/spotube/issues/851) + ([dcbb156](https://github.com/team-spotube/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) - paginated playlist and album page - ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) + ([28a5d6b](https://github.com/team-spotube/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) - **translations:** add Turkish translations - ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) + ([0c22469](https://github.com/team-spotube/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) ### Bug Fixes - "Add () to Playlist" option not showing in favorited playlists - [#904](https://github.com/KRTirtho/spotube/issues/904) - ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) + [#904](https://github.com/team-spotube/spotube/issues/904) + ([96021e1](https://github.com/team-spotube/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) - 0:00 media duration in queue after application restart - [#782](https://github.com/KRTirtho/spotube/issues/782) - ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) + [#782](https://github.com/team-spotube/spotube/issues/782) + ([83c0b49](https://github.com/team-spotube/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) - Add to Playlist Dialog memory leak - [#817](https://github.com/KRTirtho/spotube/issues/817) - ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) + [#817](https://github.com/team-spotube/spotube/issues/817) + ([fed36ec](https://github.com/team-spotube/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) - **album_card:** show loading state during adding track to queue/play - ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) + ([5633367](https://github.com/team-spotube/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) - alternative track source safearea overflow - [#876](https://github.com/KRTirtho/spotube/issues/876) - ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) + [#876](https://github.com/team-spotube/spotube/issues/876) + ([7b72a90](https://github.com/team-spotube/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) - android invalid download location Download not starting or not explaining - error [#720](https://github.com/KRTirtho/spotube/issues/720) - ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) + error [#720](https://github.com/team-spotube/spotube/issues/720) + ([d056dbf](https://github.com/team-spotube/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) - changed settings are not persisting after force stop - [#821](https://github.com/KRTirtho/spotube/issues/821) - ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) + [#821](https://github.com/team-spotube/spotube/issues/821) + ([e29a38d](https://github.com/team-spotube/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) - check for unsynced lyrics and error handling for timed lyrics query - ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) + ([1d77556](https://github.com/team-spotube/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) - **genres:** lag while scrolling - ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) + ([dc980b0](https://github.com/team-spotube/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) - infinite list disappearing for a moment everytime new page is fetched - ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) + ([1334a62](https://github.com/team-spotube/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) - last track of queue keeps repeating - [#718](https://github.com/KRTirtho/spotube/issues/718) - ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) + [#718](https://github.com/team-spotube/spotube/issues/718) + ([58e5698](https://github.com/team-spotube/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) - Navigating to settings, redirects to home page - [#812](https://github.com/KRTirtho/spotube/issues/812) - ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) + [#812](https://github.com/team-spotube/spotube/issues/812) + ([da04f06](https://github.com/team-spotube/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) - new releases section flickering on scroll glitch - ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) + ([ee94b7c](https://github.com/team-spotube/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) - **playbutton_card:** annoying animation - ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) + ([574406d](https://github.com/team-spotube/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) - scrobbling not working for first track or single track - ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) + ([0a6b54d](https://github.com/team-spotube/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) - settings page scrollbar position - ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) + ([ee82290](https://github.com/team-spotube/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) - shuffle doesn't move active track to top - ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) + ([4956bf3](https://github.com/team-spotube/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) - spotube doesn't exit properly, hangs in infinite loop - [#768](https://github.com/KRTirtho/spotube/issues/768) - ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) + [#768](https://github.com/team-spotube/spotube/issues/768) + ([353ca79](https://github.com/team-spotube/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) - trim login field padding - ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) + ([286ef83](https://github.com/team-spotube/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) - use CustomScrollView for personalized page - ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) + ([7d05c40](https://github.com/team-spotube/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) - user_playlists layout, track tile index, - ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) + ([487c2ed](https://github.com/team-spotube/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) - **windows:** media control not working - [#641](https://github.com/KRTirtho/spotube/issues/641) - ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) + [#641](https://github.com/team-spotube/spotube/issues/641) + ([7818574](https://github.com/team-spotube/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) -## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) +## [3.2.0](https://github.com/team-spotube/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) ### Features - ability to select/copy lyrics - [#802](https://github.com/KRTirtho/spotube/issues/802) - ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) -- add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) - ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) -- add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) - ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) + [#802](https://github.com/team-spotube/spotube/issues/802) + ([0eb9ee8](https://github.com/team-spotube/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) +- add Amoled theme [#724](https://github.com/team-spotube/spotube/issues/724) + ([5c5dbf6](https://github.com/team-spotube/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) +- add audio normalization [#164](https://github.com/team-spotube/spotube/issues/164) + ([da10ab2](https://github.com/team-spotube/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) - add restore default settings button - ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) + ([94c3866](https://github.com/team-spotube/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) - configurable audio normalization switch - ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) + ([c325911](https://github.com/team-spotube/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) - customizable stream/download file formats - ([#757](https://github.com/KRTirtho/spotube/issues/757)) - ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) + ([#757](https://github.com/team-spotube/spotube/issues/757)) + ([e54762b](https://github.com/team-spotube/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) - improve and unify the logging framework - ([#738](https://github.com/KRTirtho/spotube/issues/738)) - ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) + ([#738](https://github.com/team-spotube/spotube/issues/738)) + ([c7432bb](https://github.com/team-spotube/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) - LastFM scrobbling support - ([#761](https://github.com/KRTirtho/spotube/issues/761)) - ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) + ([#761](https://github.com/team-spotube/spotube/issues/761)) + ([f5bd907](https://github.com/team-spotube/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) - loading indicator for genre and personalized pages - ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) + ([ffe8d9c](https://github.com/team-spotube/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) - manual offline detection - ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) + ([854ab89](https://github.com/team-spotube/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) - show error dialog on failed to login - ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) + ([101c325](https://github.com/team-spotube/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) - sliding up player support - ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) + ([083319f](https://github.com/team-spotube/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) - swipe to open player view - ([#765](https://github.com/KRTirtho/spotube/issues/765)) - ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) + ([#765](https://github.com/team-spotube/spotube/issues/765)) + ([9aee056](https://github.com/team-spotube/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) - thicken the scrollbars & make 'em interactive for mobile - ([#764](https://github.com/KRTirtho/spotube/issues/764)) - ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) + ([#764](https://github.com/team-spotube/spotube/issues/764)) + ([84a4bcd](https://github.com/team-spotube/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) - **translations:** add Arabic Translations - ([#740](https://github.com/KRTirtho/spotube/issues/740)) - ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) + ([#740](https://github.com/team-spotube/spotube/issues/740)) + ([38493f9](https://github.com/team-spotube/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) - **translations:** add Farsi Translations - ([#760](https://github.com/KRTirtho/spotube/issues/760)) - ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) + ([#760](https://github.com/team-spotube/spotube/issues/760)) + ([fe42cfe](https://github.com/team-spotube/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) ### Bug Fixes - add libmpv1 for ubuntu-based systems - ([#739](https://github.com/KRTirtho/spotube/issues/739)) - ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) + ([#739](https://github.com/team-spotube/spotube/issues/739)) + ([5115e04](https://github.com/team-spotube/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) - add xdg-user-dirs as deps - ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) + ([f3e331e](https://github.com/team-spotube/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) - **android :** file_selector getDirectoryPath returns unusable content urls - [#720](https://github.com/KRTirtho/spotube/issues/720) - ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) + [#720](https://github.com/team-spotube/spotube/issues/720) + ([b3cf639](https://github.com/team-spotube/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) - **android:** audio doesn't resume on interruption end - ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) + ([15d466a](https://github.com/team-spotube/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) - **android:** system navigator back doesn't close player - ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) + ([20d7092](https://github.com/team-spotube/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) - get rid of overflow errors & status bar dark color - ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) + ([5bb8231](https://github.com/team-spotube/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) - keyboard shortcuts changing route but not update sidebar - ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) + ([2d93441](https://github.com/team-spotube/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) - last track repeats - ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) + ([ed6ca00](https://github.com/team-spotube/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) - minor glitches - ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) + ([e5d0aaf](https://github.com/team-spotube/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) - not fetching all followed artists - ([#759](https://github.com/KRTirtho/spotube/issues/759)) - ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) + ([#759](https://github.com/team-spotube/spotube/issues/759)) + ([c09a572](https://github.com/team-spotube/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) - use audio_service_mpris plugin - ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) + ([e29cc25](https://github.com/team-spotube/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) - valid non-ASCII characters get removed from downloaded file name - [#745](https://github.com/KRTirtho/spotube/issues/745) - ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) + [#745](https://github.com/team-spotube/spotube/issues/745) + ([a7e102f](https://github.com/team-spotube/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) -## [3.1.2](https://github.com/KRTirtho/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) +## [3.1.2](https://github.com/team-spotube/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) ### Features - **player_queue:** filtering track support - ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) + ([d4f99ec](https://github.com/team-spotube/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) - right click to open track option - ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) + ([1540999](https://github.com/team-spotube/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) - search loading animation - ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) + ([b9d5c70](https://github.com/team-spotube/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) - show loading indicator on play track - ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) + ([d12ea48](https://github.com/team-spotube/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) ### Bug Fixes - add missing dependency in debian package - ([#704](https://github.com/KRTirtho/spotube/issues/704)) - ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) + ([#704](https://github.com/team-spotube/spotube/issues/704)) + ([c987ea7](https://github.com/team-spotube/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) - hour not showing for tracks longer than 60 minutes - ([#648](https://github.com/KRTirtho/spotube/issues/648)) - ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) + ([#648](https://github.com/team-spotube/spotube/issues/648)) + ([de335f4](https://github.com/team-spotube/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) - liked tracks card play not working - ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) + ([d3e1cef](https://github.com/team-spotube/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) - limit cover image upload to allowed 256kb size - ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) -- playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) - ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) + ([1c50612](https://github.com/team-spotube/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) +- playlist grey page ([#707](https://github.com/team-spotube/spotube/issues/707)) + ([0df8d9c](https://github.com/team-spotube/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) - rewind breaks track progress bar - ([#695](https://github.com/KRTirtho/spotube/issues/695)) - ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) + ([#695](https://github.com/team-spotube/spotube/issues/695)) + ([e321743](https://github.com/team-spotube/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) - Windows memory leak due refetchOnStale user-liked-tracks - ([#705](https://github.com/KRTirtho/spotube/issues/705)) - ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) + ([#705](https://github.com/team-spotube/spotube/issues/705)) + ([142dc49](https://github.com/team-spotube/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) -## [3.1.1](https://github.com/KRTirtho/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) +## [3.1.1](https://github.com/team-spotube/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) ### Features - ability to toggle system title bar & custom title bar - ([#185](https://github.com/KRTirtho/spotube/issues/185)) - ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) + ([#185](https://github.com/team-spotube/spotube/issues/185)) + ([8d46029](https://github.com/team-spotube/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) - jump to specific time on lyric click - ([#590](https://github.com/KRTirtho/spotube/issues/590)) - ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) + ([#590](https://github.com/team-spotube/spotube/issues/590)) + ([a14fb9e](https://github.com/team-spotube/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) - paginated user albums - ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) + ([d239d64](https://github.com/team-spotube/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) - **translations:** add Russian translation - ([#661](https://github.com/KRTirtho/spotube/issues/661)) - ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), - closes [#625](https://github.com/KRTirtho/spotube/issues/625) + ([#661](https://github.com/team-spotube/spotube/issues/661)) + ([e9a0911](https://github.com/team-spotube/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), + closes [#625](https://github.com/team-spotube/spotube/issues/625) - **translations:** added Portuguese (Brazil) translation - ([#634](https://github.com/KRTirtho/spotube/issues/634)) - ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) + ([#634](https://github.com/team-spotube/spotube/issues/634)) + ([76f30a0](https://github.com/team-spotube/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) ### Bug Fixes - always fetching SponsorBlock if no segments found & download failing - ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) + ([6ced0a0](https://github.com/team-spotube/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) - debian bookworm invalid dependencies - ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) + ([633415d](https://github.com/team-spotube/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) - disable android auto for playstore version :"( - ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) + ([0f0d240](https://github.com/team-spotube/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) - infinite route push glitch - ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) + ([e90eceb](https://github.com/team-spotube/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) - jump to track going to wrong track - ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) + ([190df17](https://github.com/team-spotube/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) - last track of queue never plays & repeat playlist never works - ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) + ([c3c09f5](https://github.com/team-spotube/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) - lyrics page text contrast - ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) + ([179d536](https://github.com/team-spotube/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) - replace connectivity_plus with internet_connection_checker - ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) + ([f23e871](https://github.com/team-spotube/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) - sanitize song title for file name - ([#644](https://github.com/KRTirtho/spotube/issues/644)) - ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) + ([#644](https://github.com/team-spotube/spotube/issues/644)) + ([1a7ea0c](https://github.com/team-spotube/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) - sorting by date crashes app - ([#551](https://github.com/KRTirtho/spotube/issues/551)) - ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) + ([#551](https://github.com/team-spotube/spotube/issues/551)) + ([48e90a4](https://github.com/team-spotube/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) - window size remains same after exiting mini player - ([#618](https://github.com/KRTirtho/spotube/issues/618)) - ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) + ([#618](https://github.com/team-spotube/spotube/issues/618)) + ([fb36003](https://github.com/team-spotube/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) -## [3.1.0](https://github.com/KRTirtho/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) +## [3.1.0](https://github.com/team-spotube/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) ### Features - add android auto media session control support - ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) + ([0f5748a](https://github.com/team-spotube/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) - better track matching on youtube API - ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) + ([904a0d3](https://github.com/team-spotube/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) - blazingly™ fast download manager - ([#619](https://github.com/KRTirtho/spotube/issues/619)) - ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) + ([#619](https://github.com/team-spotube/spotube/issues/619)) + ([38dc4be](https://github.com/team-spotube/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) - paginated user playlists - ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) + ([e7c6813](https://github.com/team-spotube/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) - show error dialog on piped API 500 error - ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) + ([c69f81e](https://github.com/team-spotube/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) - **translation:** add catalan translations - ([#621](https://github.com/KRTirtho/spotube/issues/621)) - ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) + ([#621](https://github.com/team-spotube/spotube/issues/621)) + ([c94e5ba](https://github.com/team-spotube/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) - **translations:** add polish translation - ([#631](https://github.com/KRTirtho/spotube/issues/631)) - ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) + ([#631](https://github.com/team-spotube/spotube/issues/631)) + ([f90e9be](https://github.com/team-spotube/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) - web compatibility - ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) + ([cf7b849](https://github.com/team-spotube/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) ### Bug Fixes - always showing play in playlist/album views - ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) + ([8521cc5](https://github.com/team-spotube/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) - **android:** android 13 local tracks not showing up - ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) + ([e3f4344](https://github.com/team-spotube/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) - default to youtube API by default - ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) + ([5a8a1e4](https://github.com/team-spotube/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) - generate playlist page max width - ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) + ([4adf695](https://github.com/team-spotube/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) - tracks doesn't change when ended - ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) + ([aa4ac86](https://github.com/team-spotube/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) - windows media controls not working - ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) + ([ae5edd1](https://github.com/team-spotube/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) -## [3.0.1](https://github.com/KRTirtho/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) +## [3.0.1](https://github.com/team-spotube/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) ### Features - Force High Refresh Rate on some Android devices - ([#607](https://github.com/KRTirtho/spotube/issues/607)) - ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) + ([#607](https://github.com/team-spotube/spotube/issues/607)) + ([6dff099](https://github.com/team-spotube/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) - **translations:** add spanish translations - ([#585](https://github.com/KRTirtho/spotube/issues/585)) - ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) + ([#585](https://github.com/team-spotube/spotube/issues/585)) + ([042d7a4](https://github.com/team-spotube/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) - **translations:** add Simplified Chinese translation. - ([#556](https://github.com/KRTirtho/spotube/issues/556)) - ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) + ([#556](https://github.com/team-spotube/spotube/issues/556)) + ([26dbd52](https://github.com/team-spotube/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) ### Bug Fixes - alternative track source textfield safe area - ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) + ([b8c6d7e](https://github.com/team-spotube/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) - avoid sponsor block for first few seconds to not break the stream - ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) + ([d8cf2ae](https://github.com/team-spotube/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) - cache segments casting error - ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) + ([dfd60bd](https://github.com/team-spotube/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) - duration is always zero in PlayerView - ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) + ([4885dca](https://github.com/team-spotube/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) - flags not showing up and html in descriptions - ([5a563ef](https://github.com/KRTirtho/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) + ([5a563ef](https://github.com/team-spotube/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) - **linux:** crash when no secret service provider found - ([#608](https://github.com/KRTirtho/spotube/issues/608)) - ([888a4b1](https://github.com/KRTirtho/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) + ([#608](https://github.com/team-spotube/spotube/issues/608)) + ([888a4b1](https://github.com/team-spotube/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) - login dialog stays after login, mention sp_gaid in tutorial - ([b492840](https://github.com/KRTirtho/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) + ([b492840](https://github.com/team-spotube/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) - **album_sync**: negative index exception in update palette - ([#561](https://github.com/KRTirtho/spotube/issues/561)) - ([0089d47](https://github.com/KRTirtho/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) + ([#561](https://github.com/team-spotube/spotube/issues/561)) + ([0089d47](https://github.com/team-spotube/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) - remove adaptive widgets - ([#520](https://github.com/KRTirtho/spotube/issues/520)) - ([e4cbdd3](https://github.com/KRTirtho/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) -- shuffle not working ([#562](https://github.com/KRTirtho/spotube/issues/562)) - ([dc76634](https://github.com/KRTirtho/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) + ([#520](https://github.com/team-spotube/spotube/issues/520)) + ([e4cbdd3](https://github.com/team-spotube/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) +- shuffle not working ([#562](https://github.com/team-spotube/spotube/issues/562)) + ([dc76634](https://github.com/team-spotube/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) - track not skipping to next even when source is available - ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) + ([0b7affd](https://github.com/team-spotube/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) -## [3.0.0](https://github.com/KRTirtho/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) +## [3.0.0](https://github.com/team-spotube/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) ### Features - adaptive controllers - ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) + ([c8b7de0](https://github.com/team-spotube/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) - adaptive popup and bottom sheet list widget - ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) + ([ddc1c5f](https://github.com/team-spotube/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) - add generated to playlist(s) - ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) + ([c91d8c8](https://github.com/team-spotube/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) - add german locale - ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) + ([ba3f428](https://github.com/team-spotube/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) - add piped search mode - ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) + ([17a25a5](https://github.com/team-spotube/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) - add sleep timer support - ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) + ([4a75f3d](https://github.com/team-spotube/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) - adjust lyric page blurriness and player playbutton - ([54d5907](https://github.com/KRTirtho/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) + ([54d5907](https://github.com/team-spotube/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) - album art dominant color as accent color - ([#447](https://github.com/KRTirtho/spotube/issues/447)) - ([31b9249](https://github.com/KRTirtho/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) + ([#447](https://github.com/team-spotube/spotube/issues/447)) + ([31b9249](https://github.com/team-spotube/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) - **android:** add splash screen - ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) + ([c232fcc](https://github.com/team-spotube/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) - **android:** disable battery optimization for better playback - ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) + ([fe5b429](https://github.com/team-spotube/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) - artist card redesign - ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) + ([92a418c](https://github.com/team-spotube/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) - Better download manager with download progress - ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) + ([6752adc](https://github.com/team-spotube/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) - better language picker, adaptive select tile and settings section contrast - ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) + ([6430a25](https://github.com/team-spotube/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) - cache encryption for sensitive data - ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) + ([b110d83](https://github.com/team-spotube/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) - color scheme picker dialog vertical list view instead of wrap - ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) + ([bb60b01](https://github.com/team-spotube/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) - compact and adaptive playbutton card design - ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) + ([eeb8cab](https://github.com/team-spotube/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) - compact button tabbar - ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) + ([67380f6](https://github.com/team-spotube/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) - create a basic installer script - ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) + ([1763a36](https://github.com/team-spotube/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) - curved navigation bar - ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) + ([776edf8](https://github.com/team-spotube/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) - custom error toast - ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) + ([96f04c1](https://github.com/team-spotube/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) - custom playlist generator - ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) + ([f4b0d13](https://github.com/team-spotube/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) - desktop mini player support - ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) + ([471812d](https://github.com/team-spotube/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) - **desktop:** close button for minimize notification - ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) + ([1688f99](https://github.com/team-spotube/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) - **desktop:** show minimized to system tray notification - ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) + ([296f96c](https://github.com/team-spotube/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) - disable/enable smtc on demand - ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) + ([7fa50e5](https://github.com/team-spotube/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) - download button on each track - ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) + ([925fa86](https://github.com/team-spotube/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) - enable caching of queue - ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) + ([ec11af5](https://github.com/team-spotube/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) - heart button animation - ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) + ([8432dc6](https://github.com/team-spotube/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) - improved track item API and UI - ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) + ([617aa89](https://github.com/team-spotube/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) - **installer:** get latest version from Github API - ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) + ([957c085](https://github.com/team-spotube/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) - **local_tracks:** delete local track - ([#484](https://github.com/KRTirtho/spotube/issues/484)) - ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) + ([#484](https://github.com/team-spotube/spotube/issues/484)) + ([52835b2](https://github.com/team-spotube/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) - locale category/genre title - ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) + ([88137f0](https://github.com/team-spotube/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) - **locale:** add bengali translations for search page - ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) + ([a1cdbad](https://github.com/team-spotube/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) - **locale:** localize search, library, lyrics, artist with both Bengali and English - ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) + ([11fe9ec](https://github.com/team-spotube/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) - **locale:** player, playlist view, track tile bengali and english translations - ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) + ([c55133d](https://github.com/team-spotube/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) - localize settings, about, login, player queue with Bengali and English translations - ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) + ([a5c36bb](https://github.com/team-spotube/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) - logs page in settings - ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) + ([b78e7f5](https://github.com/team-spotube/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) - macos title bar spacing and lyrics page margin separate - ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) + ([a0b3771](https://github.com/team-spotube/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) - make snackbar floating - ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) + ([9dbb817](https://github.com/team-spotube/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) - merge floating player with nav bar and nav bar translucent bg - ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) + ([a90261e](https://github.com/team-spotube/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) - **mini_player:** remove window shadow - ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) + ([6259014](https://github.com/team-spotube/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) - **mini_player:** show/hide UI on hover toggle - ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) + ([2e8b647](https://github.com/team-spotube/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) - new sidebar widget and translucent bottom player - ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) + ([4ba1e70](https://github.com/team-spotube/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) - newly released albums of user followed artist - ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) + ([33cb794](https://github.com/team-spotube/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) - optimize image load + genre page and reduce page size of loaded categories - ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) + ([7131efa](https://github.com/team-spotube/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) - persistent volume percentage - ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) + ([3724bd5](https://github.com/team-spotube/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) - personal playlist recommendations - ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) + ([ae820a2](https://github.com/team-spotube/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) - piped instance picker on settings - ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) + ([bed0d3b](https://github.com/team-spotube/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) - platform specific title bar buttons - ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) + ([6267720](https://github.com/team-spotube/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) - **playback:** integrate android, ios, macos with JustAudio - ([d487fe5](https://github.com/KRTirtho/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) + ([d487fe5](https://github.com/team-spotube/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) - **playback:** use assets_audio_player to fix macos double duration problems and android high loading latency - ([1fff0f1](https://github.com/KRTirtho/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) + ([1fff0f1](https://github.com/team-spotube/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) - player details dialog and separate location of lyrics button in player page - ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) + ([ce38233](https://github.com/team-spotube/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) - **player:** add playlist related methods to audio player - ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) + ([f1080e1](https://github.com/team-spotube/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) - **player:** animated gradient background - ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) + ([49b5d0e](https://github.com/team-spotube/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) - **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv - ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) + ([eaf65b6](https://github.com/team-spotube/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) - **player:** proper coloring of elements - ([b2c4ea1](https://github.com/KRTirtho/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) + ([b2c4ea1](https://github.com/team-spotube/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) - **player:** replace bg blur with gradient, proper fg color and align title and artist name - ([159f03e](https://github.com/KRTirtho/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) + ([159f03e](https://github.com/team-spotube/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) - playlist create support for generated playlist - ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) + ([91c72f9](https://github.com/team-spotube/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) - playlist generation all parameters support - ([9877d5f](https://github.com/KRTirtho/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) + ([9877d5f](https://github.com/team-spotube/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) - **playlist,album page:** play and shuffle take full width on smaller screens, add new xs breakpoint - ([dce1b88](https://github.com/KRTirtho/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) + ([dce1b88](https://github.com/team-spotube/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) - **queue:** add track(s) for playing next - ([#460](https://github.com/KRTirtho/spotube/issues/460)) - ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) + ([#460](https://github.com/team-spotube/spotube/issues/460)) + ([cac8ea6](https://github.com/team-spotube/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) - **queue:** reorder tracks support - ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) + ([441b43b](https://github.com/team-spotube/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) - re-designed playlist/album page - ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) + ([0cedc7a](https://github.com/team-spotube/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) - re-introduce youtube API along with piped - ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) + ([b54ee96](https://github.com/team-spotube/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) - reactive volume slider and slicker bottom bar with lowered height - ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) + ([9d14517](https://github.com/team-spotube/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) - remove SponsorBlock in favor of YT Music and remove pocketbase backend track support - ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) + ([fb780da](https://github.com/team-spotube/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) - repeat button all 3 mode and disable player controls when track is fetching - ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) + ([1418378](https://github.com/team-spotube/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) - replace YouTube API with piped API - ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) + ([1ecc36d](https://github.com/team-spotube/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) - responsive playlist generate page and scrollable multi autocomplete - ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) + ([d57aad5](https://github.com/team-spotube/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) - search alternative track source - ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) + ([dfea195](https://github.com/team-spotube/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) - setup localization (l10n) and language switcher, add sidebar and navbar locale - ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) + ([f12d812](https://github.com/team-spotube/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) - show album release year - ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of + ([#387](https://github.com/team-spotube/spotube/issues/387)) and fix layout of artist's album - ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) + ([6a6ddf6](https://github.com/team-spotube/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) - show country code piped instance list - ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) + ([60328a6](https://github.com/team-spotube/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) - show loading when track metadata is being fetched, android, ios, macos enable shuffling - ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) + ([bf59570](https://github.com/team-spotube/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) - sort tracks by newest and oldest dates - ([b4713e3](https://github.com/KRTirtho/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) + ([b4713e3](https://github.com/team-spotube/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) - supabase integration - ([8bcce92](https://github.com/KRTirtho/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) -- system tray support ([#31](https://github.com/KRTirtho/spotube/issues/31)) - ([06a0437](https://github.com/KRTirtho/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) + ([8bcce92](https://github.com/team-spotube/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) +- system tray support ([#31](https://github.com/team-spotube/spotube/issues/31)) + ([06a0437](https://github.com/team-spotube/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) - track populate sibling support - ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) + ([3aeb026](https://github.com/team-spotube/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) - **translation:** add hindi and french translations using ChatGPT - ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) + ([6d836bd](https://github.com/team-spotube/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) - **translation:** add Japanase locale - ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) + ([4b52a71](https://github.com/team-spotube/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) - use system color scheme - ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) + ([862c4b8](https://github.com/team-spotube/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) - volume slider in player page - ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) + ([7abe2c1](https://github.com/team-spotube/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) - windows OS media control panel support - ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) + ([f0b426a](https://github.com/team-spotube/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) ### Bug Fixes - add to playlist dialog not showing playlist name - ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) + ([8944581](https://github.com/team-spotube/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) - album sync not working - ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) + ([74906f3](https://github.com/team-spotube/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) - alternative track source not playing new source - ([a9b5a71](https://github.com/KRTirtho/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) + ([a9b5a71](https://github.com/team-spotube/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) - **android:** use multi assetAudioPlayer instance fix patch and disable Pre-download and play by default in Android too - ([cdb3268](https://github.com/KRTirtho/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) + ([cdb3268](https://github.com/team-spotube/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) - **artist:** follower count shows as float when < 1000 - ([#482](https://github.com/KRTirtho/spotube/issues/482)) - ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) + ([#482](https://github.com/team-spotube/spotube/issues/482)) + ([fd1846e](https://github.com/team-spotube/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) - bottom navbar first item icon color not changing on primary color change - ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) + ([6eb4244](https://github.com/team-spotube/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) - cached currently playing track infinite loading - ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) + ([9401718](https://github.com/team-spotube/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) - cached queue tracks expired stream - ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) + ([ed29ab5](https://github.com/team-spotube/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) - collection currently playing state persist on restart - ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) + ([1c89e3e](https://github.com/team-spotube/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) - color not syncing and add new screenshot - ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) + ([6205501](https://github.com/team-spotube/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) - content going below bottom player or nav bar - ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) + ([1bdce9f](https://github.com/team-spotube/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) - disable background_downloader due to android build failures - ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) + ([7d23bee](https://github.com/team-spotube/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) - disable play when loading track and buffering event - ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) + ([30c933c](https://github.com/team-spotube/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) - error log - ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) + ([e3d8239](https://github.com/team-spotube/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) - excessive repaints caused by Player progress bar - ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) + ([09b24cf](https://github.com/team-spotube/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) - failed download no error icon - ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) + ([1266a3f](https://github.com/team-spotube/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) - **home:** bottom player transparency - ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) + ([20c424c](https://github.com/team-spotube/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) - language changer not working - ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) + ([7b7b1f2](https://github.com/team-spotube/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) - less frequent position updates - ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) + ([0a49b56](https://github.com/team-spotube/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) - linux mpris not showing up and overall media notification service - ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) + ([1abcad1](https://github.com/team-spotube/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) - local tracks getting fetched on first load - ([73c012c](https://github.com/KRTirtho/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) + ([73c012c](https://github.com/team-spotube/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) - local tracks not working when there's a invalid music file in the folder - ([5855820](https://github.com/KRTirtho/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) + ([5855820](https://github.com/team-spotube/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) - lyrics page blur in player and cut off text when line too big - ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) + ([6b4584e](https://github.com/team-spotube/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) - macos build by removing media_kit native event loop - ([62fc773](https://github.com/KRTirtho/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) + ([62fc773](https://github.com/team-spotube/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) - macos build error, mobile player duration and playing state and background disposal of player - ([be91e33](https://github.com/KRTirtho/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) + ([be91e33](https://github.com/team-spotube/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) - **macos,ios:** use regular shared prefs - ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) + ([1b5bfec](https://github.com/team-spotube/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) - memoize child of animated widget and make player bg animation faster - ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) + ([fcb5c8f](https://github.com/team-spotube/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) - mini player not working in release mode - ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) + ([28ff321](https://github.com/team-spotube/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) - **mkPlayer:** remove method and wrong active index on modifying playlist - ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) + ([3bafa7b](https://github.com/team-spotube/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) - mobile audio notification not working - ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) + ([8f9303b](https://github.com/team-spotube/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) - multiple instance of theme - ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) + ([4ec0424](https://github.com/team-spotube/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) - navigation to settings not working - ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) + ([ce10aa1](https://github.com/team-spotube/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) - no progress update when track changed - ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) + ([6ae8964](https://github.com/team-spotube/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) - null exception on proxy playlist and audio player - ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) + ([a455a89](https://github.com/team-spotube/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) - overflowing clickable artists links - ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) + ([4077fac](https://github.com/team-spotube/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) - personalized playlists not loading - ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) + ([caa3408](https://github.com/team-spotube/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) - playback not moving to next track after a track ends - ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) + ([27e8acb](https://github.com/team-spotube/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) - **player:** gradient bg not taking full height - ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) + ([62ad86e](https://github.com/team-spotube/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) - **player:** playback element placement - ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) + ([5e47faa](https://github.com/team-spotube/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) - **player:** queue button not showing when not logged in - ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) + ([6c2d655](https://github.com/team-spotube/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) - **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip - ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) + ([1f32554](https://github.com/team-spotube/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) - playlist generate slider shape - ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) + ([2b35c04](https://github.com/team-spotube/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) - pop sheet list not scrollable - ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) + ([cca5625](https://github.com/team-spotube/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) - re-enable add to queue and play next support, favorite button query exceptions - ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) + ([e529c79](https://github.com/team-spotube/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) - re-enable download manager - ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) + ([ea45c4f](https://github.com/team-spotube/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) - remove unnecessary broadcast stream conversions - ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) + ([bf04962](https://github.com/team-spotube/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) - remove useBreakpoints as it clogs up memory with unnecessary state updates - ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) + ([e1c0f5c](https://github.com/team-spotube/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) - replace download multiple pops and add translations - ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) + ([4a21249](https://github.com/team-spotube/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) - screen breakpoints and persist lyrics delay across screens - ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) + ([df79638](https://github.com/team-spotube/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) - sidebar task counter badge and bottom player play button progress color - ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) + ([af278d8](https://github.com/team-spotube/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) - status bar color of playlist/album page - ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) + ([65fa3cb](https://github.com/team-spotube/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) - system color scheme not persisting on restart when system color scheme changed - ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) + ([e04515d](https://github.com/team-spotube/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) - track collection view status bar not transparent - ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) + ([9251121](https://github.com/team-spotube/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) - track doesn't play after change - ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) + ([17e5ab6](https://github.com/team-spotube/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) - track stops at last second - ([f554f6d](https://github.com/KRTirtho/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) + ([f554f6d](https://github.com/team-spotube/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) - **track_collection_view:** keyboard focus on scroll and no space for search results in playlist/album - ([7a8bd92](https://github.com/KRTirtho/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) + ([7a8bd92](https://github.com/team-spotube/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) - track_table_view table headers - ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) + ([d88d287](https://github.com/team-spotube/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) - track_tile active and blacklist color, playbutton card action positioning - ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) + ([3f5a1b9](https://github.com/team-spotube/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) - use id based source getters instead of index - ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) + ([a074463](https://github.com/team-spotube/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) -### [2.7.1](https://github.com/KRTirtho/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) +### [2.7.1](https://github.com/team-spotube/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) ### Bug Fixes - fallback for lyrics when anonymous - ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) + ([f160ec7](https://github.com/team-spotube/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) - **android:** audio notification stuck in play state - ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) + ([448c9b3](https://github.com/team-spotube/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) - **macos:** crashing on startup - ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) + ([c46b428](https://github.com/team-spotube/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) - spotify query hooks overriding default query params - ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) + ([ec9a02e](https://github.com/team-spotube/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) -## [2.7.0](https://github.com/KRTirtho/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) +## [2.7.0](https://github.com/team-spotube/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) ### Features - add or remove track, playlist or album to queue support - ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) + ([b8f3493](https://github.com/team-spotube/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) - basic command line argument support - ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) + ([025c1ae](https://github.com/team-spotube/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) - black list artist or track - ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) + ([947c143](https://github.com/team-spotube/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) - bring pre download on desktop, disable pre download for long videos - ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) + ([1d82bb0](https://github.com/team-spotube/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) - category/genre filter - ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) + ([1dfec05](https://github.com/team-spotube/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) - centralized icon collection with new icon set and nav bar labels hidden - ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) + ([e7acb9e](https://github.com/team-spotube/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) - compact search bar for genres and user_local_tracks page - ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) + ([c343ccc](https://github.com/team-spotube/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) - compatibility with fl-query nextPage method change - ([7617439](https://github.com/KRTirtho/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) + ([7617439](https://github.com/team-spotube/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) - configure pocketbase, generate dart types, update playback to use server instead of hive cache - ([ad90c11](https://github.com/KRTirtho/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) + ([ad90c11](https://github.com/team-spotube/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) - failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions - ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) + ([d68d150](https://github.com/team-spotube/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) - **home:** personalized section - ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) + ([9080441](https://github.com/team-spotube/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) - individual shuffle and repeat/loop button of player - ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) + ([f79223c](https://github.com/team-spotube/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) - **lyrics:** use official spotify API for fetching lyrics and add zoom controls - ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) + ([10d0660](https://github.com/team-spotube/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) - **mobile:** pull to refresh support in all refreshable list views - ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) + ([9f959ce](https://github.com/team-spotube/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) - new logo and compact search in playlist/album in mobile - ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) + ([dc96cb3](https://github.com/team-spotube/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) - search/filter tracks inside playlist or album - ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) + ([a06cd0d](https://github.com/team-spotube/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) - show snackbar on adding playlist or tracks to queue - ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) + ([6bc1d32](https://github.com/team-spotube/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) - **theme:** use material3 monet for colors and remove background color preference - ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) + ([60ede5f](https://github.com/team-spotube/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) - use catcher to handle exceptions - ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) + ([84d94b0](https://github.com/team-spotube/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) - use typed assets instead of hard coded paths - ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) + ([59561ab](https://github.com/team-spotube/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) - user local tracks searchbar - ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) + ([e7f3f4e](https://github.com/team-spotube/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) - **user-library:** filtering support for user albums and user artists - ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) + ([0b58155](https://github.com/team-spotube/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) - **user-library:** search for user playlists - ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) + ([af4d56f](https://github.com/team-spotube/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) ### Bug Fixes - **about:** license text hidden in the bottom of smaller screen devices - ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) + ([e158dd0](https://github.com/team-spotube/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) - **about:** wrong link of License - ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) + ([a4a7f1a](https://github.com/team-spotube/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) - genre and sidebar user logo not loading - ([710f172](https://github.com/KRTirtho/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) + ([710f172](https://github.com/team-spotube/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) - lyrics modal sheet out of safe area so use 80% of screen height instead of full - ([3db28f4](https://github.com/KRTirtho/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) + ([3db28f4](https://github.com/team-spotube/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) - lyrics not changing on track change - ([c809d2d](https://github.com/KRTirtho/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) + ([c809d2d](https://github.com/team-spotube/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) - lyrics not refetching when tracked changed while being in another page and sidebar user avatar not showing on startup - ([bd12675](https://github.com/KRTirtho/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) + ([bd12675](https://github.com/team-spotube/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) - macOS logo placement - ([c6a5d5f](https://github.com/KRTirtho/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) + ([c6a5d5f](https://github.com/team-spotube/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) - mobile track collection search bar position and page_window_title_bar exception on mobile platforms - ([d0aaa97](https://github.com/KRTirtho/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) + ([d0aaa97](https://github.com/team-spotube/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) - **play_overlay:** show progress indicator on song loading - ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) + ([7803a48](https://github.com/team-spotube/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) - **playback:** not skipping track's sponsorblock segments - ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) + ([60a5847](https://github.com/team-spotube/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) - playbutton card play state not changing - ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) + ([ee46d09](https://github.com/team-spotube/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) - **playbutton_card:** play and add to queue needs 2 clicks work - ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) + ([bdd7098](https://github.com/team-spotube/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) - **playbutton_card:** play and non play state correction - ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) + ([b327ffb](https://github.com/team-spotube/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) - **playbutton_card:** title text overflow - ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) + ([39ee0a9](https://github.com/team-spotube/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) - **playbutton:** playing state is not updating when playlist is actually playing - ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) + ([9bad8c9](https://github.com/team-spotube/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) - **player_queue:** large clear button and macos exception - ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) + ([0e43504](https://github.com/team-spotube/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) - **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks - ([42b3e11](https://github.com/KRTirtho/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) + ([42b3e11](https://github.com/team-spotube/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) - pre downloading not working properly, audio service circular deps and sibling not loading for backend track - ([3ccb525](https://github.com/KRTirtho/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) + ([3ccb525](https://github.com/team-spotube/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) - search track play button isn't working - ([0751f5e](https://github.com/KRTirtho/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) + ([0751f5e](https://github.com/team-spotube/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) - **search:** grey screen, only tracks update on new search string, playlists,albums,artists show up before hitting return/submit - ([a774817](https://github.com/KRTirtho/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) + ([a774817](https://github.com/team-spotube/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) - **search:** has to submit twice for search results - ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) + ([f5dc76a](https://github.com/team-spotube/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) - titlebar maximize+restore button not working and less responsive title bar buttons - ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) + ([8a6ba3b](https://github.com/team-spotube/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) - **track_collection_view:** hide search bar when sliver is collapsed - ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) + ([3d6d244](https://github.com/team-spotube/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) - **track_tile:** cannot see track index above 99 - ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) + ([78b3273](https://github.com/team-spotube/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) - **track_tile:** track action popup not showing on narrow screens - ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) + ([0c54f2d](https://github.com/team-spotube/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) - **ui:** scaffold exception in fluent_ui - ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) + ([8ce2192](https://github.com/team-spotube/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) - use chosen market for new release - ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) + ([c6bf9b6](https://github.com/team-spotube/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) -## [2.6.0](https://github.com/KRTirtho/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) +## [2.6.0](https://github.com/team-spotube/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) ### Features - add selected tracks to playlists, optimistic playlist remove track - ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) + ([3386dac](https://github.com/team-spotube/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) - added shuffle button in playlist and album section - ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) + ([1fad95f](https://github.com/team-spotube/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) - **android-playback:** option to download track bytes and play instead of Streaming - ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) + ([dcc8ba5](https://github.com/team-spotube/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) - change default platform option and platform specific back button - ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) + ([36c5e02](https://github.com/team-spotube/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) - dialog logo for macos, settings more width for country picker - ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) + ([5e96913](https://github.com/team-spotube/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) - initial platform_ui integration - ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) + ([9eee573](https://github.com/team-spotube/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) - libadwaita theming, track tile and PlayButtonCard play button icon fix - ([e795e23](https://github.com/KRTirtho/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) + ([e795e23](https://github.com/team-spotube/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) - **lyrics:** tabs for both synced and static lyrics - [#182](https://github.com/KRTirtho/spotube/issues/182) - ([6b6907a](https://github.com/KRTirtho/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) + [#182](https://github.com/team-spotube/spotube/issues/182) + ([6b6907a](https://github.com/team-spotube/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) - new refined about page, update checker only check for same update channel - ([4cadfa9](https://github.com/KRTirtho/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) + ([4cadfa9](https://github.com/team-spotube/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) - pause track when seeking forward/back and keep audio session alive when paused/interrupted - ([bc8a04e](https://github.com/KRTirtho/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) + ([bc8a04e](https://github.com/team-spotube/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) - platform bottom navigation bar add - ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) + ([ff14469](https://github.com/team-spotube/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) - platform slider and progress indicator integration - ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) + ([46b00ba](https://github.com/team-spotube/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) - platform title bar buttons add - ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) + ([54048cb](https://github.com/team-spotube/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) - **playback:** change current track youtube source panel and tooltips for player icon buttons - ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) + ([4b21cc8](https://github.com/team-spotube/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) - Player and Playbutton theme respect to platform - ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) + ([512446d](https://github.com/team-spotube/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) - player queue and sibling tracks platform decoration - ([39a7794](https://github.com/KRTirtho/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) + ([39a7794](https://github.com/team-spotube/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) - **PlayerView:** shortcut button for opening lyrics - [#273](https://github.com/KRTirtho/spotube/issues/273) - ([1d4847a](https://github.com/KRTirtho/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) + [#273](https://github.com/team-spotube/spotube/issues/273) + ([1d4847a](https://github.com/team-spotube/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) - rename files to snake_case and reorganize folder structure - ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) + ([7c25e1c](https://github.com/team-spotube/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) - replace all types of buttons with platform buttons - ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) + ([69739b4](https://github.com/team-spotube/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) - rpm packaging support - ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) + ([067e9ac](https://github.com/team-spotube/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) - **search:** infinite scroll for tracks, artists, playlists and albums - ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) + ([e6761a6](https://github.com/team-spotube/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) - set platform to default platform on start up - ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) + ([472da6b](https://github.com/team-spotube/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) - shuffle keep playing track at top, linux title bar drag no working - ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) + ([1223cf2](https://github.com/team-spotube/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) - sidebar download count and proper progress color in playbutton - ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) + ([a10bc5b](https://github.com/team-spotube/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) - static shimmer for track tile, playbutton card and track tile - ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) + ([3ed8b0f](https://github.com/team-spotube/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) - tablet mode navigation bar & windows semi transparent bg, - ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) + ([3282370](https://github.com/team-spotube/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) - **title_bar:** platform specific title bar - ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) + ([e659e3c](https://github.com/team-spotube/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) - titlebar complete compatibility, platform specific login, library tabbar in titlebar - ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) + ([b3c27d1](https://github.com/team-spotube/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) - use platform checkbox - ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) + ([2211505](https://github.com/team-spotube/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) - window blur effect add - ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) + ([b0db5e7](https://github.com/team-spotube/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) ### Bug Fixes - **ArtistCard:** linux shadow - ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) + ([c186881](https://github.com/team-spotube/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) - **auth:** refresh access token timer not working - ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) + ([b3ac5ca](https://github.com/team-spotube/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) - bottom navigation bar settings tile not active when selected - ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) + ([43557e4](https://github.com/team-spotube/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) - dialog logo in android, lyrics visible timer adjust button - ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) + ([3c6803b](https://github.com/team-spotube/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) - heart button showing when not logged in, wrong login redirect - ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) + ([4dc26af](https://github.com/team-spotube/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) - horizontal infinite lists doesn't fill the screen - ([69995be](https://github.com/KRTirtho/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) + ([69995be](https://github.com/team-spotube/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) - ios dialog action buttons, local tracks crashing app, shimmer color and android wrong status bar color - ([90c1200](https://github.com/KRTirtho/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) + ([90c1200](https://github.com/team-spotube/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) - **login:** not working in android in Brazil or Ukraine regions - ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) + ([0b79a11](https://github.com/team-spotube/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) - **macos:** black text in dark mode - ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) + ([fb9c0e4](https://github.com/team-spotube/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) - **macos:** white text color in dark mode, text field white background - ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) + ([e086b52](https://github.com/team-spotube/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) - **mobile:** SafeArea bugs and back button color - ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) + ([a8330ef](https://github.com/team-spotube/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) - null exception in themes - ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) + ([9465d92](https://github.com/team-spotube/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) - platform_ui local path - ([00d0d38](https://github.com/KRTirtho/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) + ([00d0d38](https://github.com/team-spotube/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) - player view artist link when local playlist is playing, lyric delay adjust button alignment - ([ee5c417](https://github.com/KRTirtho/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) + ([ee5c417](https://github.com/team-spotube/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) - remove windows background - ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) + ([6942964](https://github.com/team-spotube/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) - search field ios dark icon , lyrics tabbar ios background color - ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) + ([be56ad4](https://github.com/team-spotube/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) - settings Title alignment and play button card ripple effect in other platforms - ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) + ([3b6bf27](https://github.com/team-spotube/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) - shuffle play logic - ([65cad07](https://github.com/KRTirtho/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) + ([65cad07](https://github.com/team-spotube/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) - small minwidth of window in desktop, linux wrong light theme accent color, search field transparent background - ([5b0e22c](https://github.com/KRTirtho/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) + ([5b0e22c](https://github.com/team-spotube/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) - tooltips of menu and adaptive pop up menu - ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) + ([261aaf1](https://github.com/team-spotube/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) - update download dialog blocking the UI - ([3925f74](https://github.com/KRTirtho/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) + ([3925f74](https://github.com/team-spotube/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) - user playlists not updating after creating/deleting, artist follow not updating after follow/unfollow - ([6cc2a18](https://github.com/KRTirtho/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) + ([6cc2a18](https://github.com/team-spotube/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) - **windows:** windows global title bar - ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) + ([bd18f19](https://github.com/team-spotube/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) -## [2.5.0](https://github.com/KRTirtho/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) +## [2.5.0](https://github.com/team-spotube/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) ### Features - animated transition of root PageWindowTitleBar - ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) + ([ff35e06](https://github.com/team-spotube/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) - **auth:** new authentication flow using cookies and webview in android - ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) + ([756b910](https://github.com/team-spotube/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) - **downloader:** replace /skip all choice for downloaded tracks - ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) + ([88d7ce5](https://github.com/team-spotube/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) - implemented go_route shell/nested route - ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) + ([3e498a4](https://github.com/team-spotube/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) - **keyboard shortcuts:** play/pause on space, seek position on left/right - ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) + ([2734454](https://github.com/team-spotube/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) - **keyboard-shortcuts:** home sidebar tab navigation and close app - ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) + ([8f258e7](https://github.com/team-spotube/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) - smoother list using fl_query and waypoint - ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) + ([c77b0e1](https://github.com/team-spotube/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) - sort tracks in playlist, album and local tracks - ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) + ([cb4bd25](https://github.com/team-spotube/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) - use of smaller sized images in `TrackTile` - ([0ca97b4](https://github.com/KRTirtho/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) + ([0ca97b4](https://github.com/team-spotube/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) - volume slider mouse scroll and preference for Rotating Album Art - [#255](https://github.com/KRTirtho/spotube/issues/255) - ([edb6f3c](https://github.com/KRTirtho/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) + [#255](https://github.com/team-spotube/spotube/issues/255) + ([edb6f3c](https://github.com/team-spotube/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) ### Bug Fixes - **android:** file_picker and permission_handler failure for sdk < 33 - ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) + ([139d4dc](https://github.com/team-spotube/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) - cached local track is fetched from network - ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) + ([abf4a57](https://github.com/team-spotube/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) - categories not showing for oauth exception - ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) + ([4df917e](https://github.com/team-spotube/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) - **desktop:** maximized window size is stored and window maximized state doesn't persist - ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) + ([91d5d10](https://github.com/team-spotube/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) - local audio doesn't get refreshed after getting permission - ([618c6da](https://github.com/KRTirtho/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) + ([618c6da](https://github.com/team-spotube/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) - no appropriate output when playlist is empty - [#201](https://github.com/KRTirtho/spotube/issues/201) - ([dbb81de](https://github.com/KRTirtho/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) + [#201](https://github.com/team-spotube/spotube/issues/201) + ([dbb81de](https://github.com/team-spotube/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) - PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView - ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) + ([0ebac05](https://github.com/team-spotube/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) - **web:** not building due to metadata_god ffi - ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) + ([1191bf2](https://github.com/team-spotube/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) -## [2.4.1](https://github.com/KRTirtho/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) +## [2.4.1](https://github.com/team-spotube/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) ### Features - add macos audio metadata tags support - ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) + ([5866b0f](https://github.com/team-spotube/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) - remove macos bounds for reading and writing audio metadata - ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) + ([16064f6](https://github.com/team-spotube/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) - **search:** horizontal swipe scroll support for Desktop platform - ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) + ([d5ff927](https://github.com/team-spotube/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) ### Bug Fixes - **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app - ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) + ([4279541](https://github.com/team-spotube/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) - **layout:** Fix adaptive UI not working correctly by providing a overriding option - ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) + ([8c7adde](https://github.com/team-spotube/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) - **local-track:** throwing exception when downloadLocation is empty - ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) + ([1a3556d](https://github.com/team-spotube/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) -## [2.4.0](https://github.com/KRTirtho/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) +## [2.4.0](https://github.com/team-spotube/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) ### Features - Ability to change download location added - ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) + ([816707c](https://github.com/team-spotube/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) - add download multi tracks support for mobile platform - ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) + ([0476bf7](https://github.com/team-spotube/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) - add download queue for desktop & initial playlist download support - ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) + ([08f913e](https://github.com/team-spotube/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) - add download tab on library - ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) + ([8d77b69](https://github.com/team-spotube/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) - add web support although nothing works just as expected - ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) + ([2818ed5](https://github.com/team-spotube/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) - **broken:** Broken Warning! Initial Local Audio Player - ([c3bf511](https://github.com/KRTirtho/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) + ([c3bf511](https://github.com/team-spotube/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) - **download:** track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog - ([e217553](https://github.com/KRTirtho/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) + ([e217553](https://github.com/team-spotube/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) - **local-tracks:** complete support for local tracks - ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) + ([e206f16](https://github.com/team-spotube/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) - **mpris:** MPRIS metadata are now updated in realtime - ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) + ([d9addcd](https://github.com/team-spotube/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) - **playback:** add repeat track support - [#166](https://github.com/KRTirtho/spotube/issues/166) - ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) + [#166](https://github.com/team-spotube/spotube/issues/166) + ([cae9993](https://github.com/team-spotube/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) - **synced-lyrics:** animated active text size - ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) + ([531fae6](https://github.com/team-spotube/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) - **ui:** adaptive TrackTile actions & Setting ListTile - ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) + ([615d5ce](https://github.com/team-spotube/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) ### Bug Fixes - **adaptive-list-tile:** dialog content not updating when content has changed - ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) + ([a1d4230](https://github.com/team-spotube/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) - album & playlist card, player view and album view play button logic - ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) + ([55852bd](https://github.com/team-spotube/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) - **docs:** indentions - ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) + ([4a291d5](https://github.com/team-spotube/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) - **downloader:** downloaded track is corrupted for tagging - ([2ab1fba](https://github.com/KRTirtho/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) + ([2ab1fba](https://github.com/team-spotube/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) - **downloader:** flutter downloader exception on desktop platform and too much width of TrackTile index no. - ([d668760](https://github.com/KRTirtho/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) + ([d668760](https://github.com/team-spotube/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) - dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous - ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) + ([307a8e2](https://github.com/team-spotube/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) - flutter_downloader manifest configuration breaking android support - ([f3a0f78](https://github.com/KRTirtho/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) + ([f3a0f78](https://github.com/team-spotube/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) - login screen not using safearea and no dialog bg-color found on light mode in AdaptivePopupMenuButton - ([92bc611](https://github.com/KRTirtho/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) + ([92bc611](https://github.com/team-spotube/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) - **performance:** always running marquee text causes high GPU usage - [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow + [#175](https://github.com/team-spotube/spotube/issues/175) and UserArtist overflow on smaller displays - ([a23ce61](https://github.com/KRTirtho/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) + ([a23ce61](https://github.com/team-spotube/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) - **playback:** shuffle button sometimes gets stuck and stops working - [#183](https://github.com/KRTirtho/spotube/issues/183) - ([4240433](https://github.com/KRTirtho/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) + [#183](https://github.com/team-spotube/spotube/issues/183) + ([4240433](https://github.com/team-spotube/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) - **player-overlay:** flickering when a track is changed or navigated to another page - ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) + ([e48b67c](https://github.com/team-spotube/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) - **sidebar:** user image url - ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) + ([747efc6](https://github.com/team-spotube/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) - **synced-lyrics:** active lyrics contrast ratio - ([aba1ba9](https://github.com/KRTirtho/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) + ([aba1ba9](https://github.com/team-spotube/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) - tabbar overflow in small screen, artist card too small title and synced lyrics contrast increased - ([585de8c](https://github.com/KRTirtho/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) + ([585de8c](https://github.com/team-spotube/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) # v2.3.0 @@ -1662,17 +1662,17 @@ installing the new one - OS Media controls for Linux. Keyboard media keys now work in Linux - New better, consistent & predictable Audio engine with proper event firing - support (https://github.com/KRTirtho/spotube/pull/131) + support (https://github.com/team-spotube/spotube/pull/131) - Custom Lyrics delay time. Can be used to delay negative amount of time too - Playback Queue View support. Currently playing tracks or playlist can be viewed or changed from it or for doing other actions too - (https://github.com/KRTirtho/spotube/issues/126) + (https://github.com/team-spotube/spotube/issues/126) - Android SeekBar support in Notification Panel & Lock Screen - New Blur background design adapted to multiple components including Floating Player, Player View & Lyrics Tab - New HighContrast Color Scheme addition which reduces battery consumption on OLED or AMOLED display devices - (https://github.com/KRTirtho/spotube/issues/137) + (https://github.com/team-spotube/spotube/issues/137) ### Improved @@ -1693,9 +1693,9 @@ - Player doesn't stop when Application is exits or closed - First Track of Playlist doesn't load sometimes - Download Button doesn't show done symbol when track is already saved - (https://github.com/KRTirtho/spotube/issues/138) + (https://github.com/team-spotube/spotube/issues/138) - Downloaded Music is 0kb sized when lyrics are downloaded alongside - (https://github.com/KRTirtho/spotube/issues/122) + (https://github.com/team-spotube/spotube/issues/122) # v2.2.1 @@ -1717,7 +1717,7 @@ - Update checker - Share options for playlists & track - Android Skip to Next/Previous track from notification/lockscreen - (https://github.com/KRTirtho/spotube/issues/91) + (https://github.com/team-spotube/spotube/issues/91) - Custom Accent Color Scheme support (Dark + Light) - Custom Background Color Scheme support (Dark + Light) - User customizable Audio Quality Option @@ -1726,7 +1726,7 @@ - Caching in Playlists, Album, Search, Playlist Categories, Artist Profile & Lyrics - M1 Mac support via MacOS Universal Binary (untested) - (https://github.com/KRTirtho/spotube/pull/87) + (https://github.com/team-spotube/spotube/pull/87) ### Improved @@ -1737,7 +1737,7 @@ ### Bug fixes - private playlists of current user aren't shown fix - (https://github.com/KRTirtho/spotube/issues/92) + (https://github.com/team-spotube/spotube/issues/92) - refresh token error causing re-login (culprit: internal lib spotify-dart) - Typo in Login instructions URL @@ -1759,23 +1759,23 @@ - Download track. Checks if already exists & replaces on user command - Wide screen responsiveness & adaptation - Bigger Title display (replaced word-break with Marquee Text for better - visibility) (https://github.com/KRTirtho/spotube/pull/47) + visibility) (https://github.com/team-spotube/spotube/pull/47) ### Bug fixes - Sequential playlist playback not working with latest webkit2gtk - (https://github.com/KRTirtho/spotube/issues/46) + (https://github.com/team-spotube/spotube/issues/46) - Theme modification state doesn't persist - (https://github.com/KRTirtho/spotube/issues/54) + (https://github.com/team-spotube/spotube/issues/54) - Wrong URI path for "Login with Spotify" tutorial - (https://github.com/KRTirtho/spotube/issues/69) + (https://github.com/team-spotube/spotube/issues/69) - Card shadow showing in the background of TitleBar & Searchbar # v2.0.0 ### New -- Android Support https://github.com/KRTirtho/spotube/issues/24 +- Android Support https://github.com/team-spotube/spotube/issues/24 - Responsive UI (Mobile, Tablet) - Anonymous/Guest Account - Mini floating player @@ -1798,7 +1798,7 @@ ### Bug fixes -- Can't play any song in macos https://github.com/KRTirtho/spotube/issues/23 +- Can't play any song in macos https://github.com/team-spotube/spotube/issues/23 - Downloaded tracks can't be played as they're WebAudio (.weba) instead of MP3 - delay while changing Playlist/Single tracks @@ -1819,9 +1819,9 @@ ### Bug fixes - broken authentication IPC on Mac OS - (https://github.com/KRTirtho/spotube/pull/18) + (https://github.com/team-spotube/spotube/pull/18) - Mac OS's global appmenu's default APP_NAME replaced with Spotube -- location of back button on macOS (https://github.com/KRTirtho/spotube/pull/21) +- location of back button on macOS (https://github.com/team-spotube/spotube/pull/21) - windows titlebar buttons appears on Mac OS - genius access_token not loading on initial app start @@ -1829,7 +1829,7 @@ ### New -- MacOS support https://github.com/KRTirtho/spotube/pull/7 +- MacOS support https://github.com/team-spotube/spotube/pull/7 - Download currently playing track to `/home//Downloads/Spotube` (Linux, MacOS) or `C:\Users\\Downloads\Spotube` (Windows) - Play playlist from any song (index) instead of only the first track diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index db77a5f1..dc1cebaf 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -30,19 +30,19 @@ All types of contributions are encouraged and valued. See the [Table of Contents ## Code of Conduct This project and everyone participating in it is governed by the -[Spotube Code of Conduct](https://github.com/KRTirtho/spotube/blob/master/CODE_OF_CONDUCT.md). +[Spotube Code of Conduct](https://github.com/team-spotube/spotube/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to krtirtho@gmail.com. ## I Have a Question -> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/KRTirtho/spotube#readme). +> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/team-spotube/spotube#readme). -Before you ask a question, it is best to search for existing [Issues](https://github.com/KRTirtho/spotube/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. +Before you ask a question, it is best to search for existing [Issues](https://github.com/team-spotube/spotube/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. If you then still feel the need to ask a question and need clarification, we recommend the following: -- Open an [Discussion](https://github.com/KRTirtho/spotube/discussions/new) with the question label. +- Open an [Discussion](https://github.com/team-spotube/spotube/discussions/new) with the question label. - Provide as much context as you can about what you're running into. - Provide project and platform versions (flutter, dart, pub, linux etc..), depending on what seems relevant. @@ -61,8 +61,8 @@ We will then take care of the issue as soon as possible. A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. - Make sure that you are using the latest version. -- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/KRTirtho/spotube#readme). If you are looking for support, you might want to check [this section](#i-have-a-question)). -- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/KRTirtho/spotubeissues?q=label%3Abug). +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/team-spotube/spotube#readme). If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/team-spotube/spotube/issues?q=label%3Abug). - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. - Collect information about the bug: - Stack trace (Traceback) @@ -77,7 +77,7 @@ A good bug report shouldn't leave others needing to chase you up for more inform We use GitHub issues to track bugs and errors. If you run into an issue with the project: -- Open an [Issue](https://github.com/KRTirtho/spotube/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) +- Open an [Issue](https://github.com/team-spotube/spotube/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) - Explain the behavior you would expect and the actual behavior. - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. - Provide the information you collected in the previous section. @@ -97,15 +97,15 @@ This section guides you through submitting an enhancement suggestion for Spotube #### Before Submitting an Enhancement - Make sure that you are using the latest version. -- Read the [documentation](https://github.com/KRTirtho/spotube#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration. -- Perform a [search](https://github.com/KRTirtho/spotube/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +- Read the [documentation](https://github.com/team-spotube/spotube#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration. +- Perform a [search](https://github.com/team-spotube/spotube/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset #### How Do I Submit a Good Enhancement Suggestion? -Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirtho/spotube/issues). +Enhancement suggestions are tracked as [GitHub issues](https://github.com/team-spotube/spotube/issues). - Use a **clear and descriptive title** for the issue to identify the suggestion. - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. diff --git a/README.md b/README.md index 129645d7..610016b1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A cross-platform extensible open-source music streaming platform.
Bring your own music metadata/playlist/audio-source with plugins created by community or by yourself. A small step towards the decentralized music streaming era! -
Visit the website +Visit the website Discord Server Support me on Patron @@ -37,21 +37,21 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm ### ⬇️ Download -Download Spotube from the Official Website: https://spotube.cc +Download Spotube from the Official Website: https://spotube.org ### 🔄 Nightly Builds -Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). +Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.org/downloads/nightly). ## 🕳️ Building from source -GitHub Workflow Status +GitHub Workflow Status You can compile Spotube's source code by [following these instructions](CONTRIBUTION.md#your-first-code-contribution). ## 👥 The Spotube team -- [Kingkor Roy Tirtho](https://github.com/KRTirtho) - The Founder, Maintainer and Lead Developer +- [Kingkor Roy Tirtho](https://github.com/team-spotube) - The Founder, Maintainer and Lead Developer - [Owen Connor](https://github.com/owencz1998) - The Cool Discord Moderator - [Piotr Rogowski](https://github.com/karniv00l) - The MacOS Developer - [Rusty Apple](https://github.com/RustyApple) - The Mysterious Unknown Guy diff --git a/cliff.toml b/cliff.toml index 311a7d7c..1c98d090 100644 --- a/cliff.toml +++ b/cliff.toml @@ -27,7 +27,7 @@ render_always = true # An array of regex based postprocessors to modify the changelog. postprocessors = [ # Replace the placeholder with a URL. - { pattern = '', replace = "https://github.com/KRTirtho/spotube" }, + { pattern = '', replace = "https://github.com/team-spotube/spotube" }, ] # render body even when there are no releases to process # render_always = true diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1e1af578..cab6974a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -40,11 +40,11 @@ plugins { kotlin("plugin.atomicfu") version libs.versions.kotlin } -val bundleId = "dev.krtirtho.spotube" +val bundleId = "org.spotube.spotube" val version = "6.0.0" val buildNumber = 46 val targetName = "Spotube" -val homePageUrl = "https://spotube.cc" +val homePageUrl = "https://spotube.org" val repositoryUrl = "https://github.com/team-spotube/spotube" val appVersionName = version val appBuildNumber = buildNumber @@ -55,7 +55,7 @@ val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/comm val generateAppBuildInfo by tasks.registering(Copy::class) { description = "Generate AppBuildInfo.kt" from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template")) - into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/dev/krtirtho/spotube")) + into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/org/spotube/spotube")) rename("AppBuildInfo.kt.template", "AppBuildInfo.kt") expand( mapOf( @@ -287,7 +287,7 @@ kotlin { } android { - namespace = "dev.krtirtho.spotube" + namespace = "org.spotube.spotube" compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { @@ -350,7 +350,7 @@ dependencies { } nucleus.application { - mainClass = "dev.krtirtho.spotube.MainKt" + mainClass = "org.spotube.spotube.MainKt" jvmArgs += listOf( "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeDownloader.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt similarity index 98% rename from composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeDownloader.kt rename to composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt index afb9b395..fc0fa47e 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeDownloader.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.paths.Paths import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt similarity index 96% rename from composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt rename to composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index c476f876..39cf7930 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import io.ktor.http.Url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt similarity index 88% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt index 55ab3202..aed7b61e 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import android.content.Intent import android.os.Build @@ -23,10 +23,10 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler -import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.media.PlaybackService +import org.spotube.spotube.core.deeplink.ExternalUriHandler +import org.spotube.spotube.core.newpipe.NewPipeDownloader +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.media.PlaybackService import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt similarity index 92% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt index d93242aa..fff70175 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import android.app.Application -import dev.krtirtho.spotube.core.di.initKoin -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.paths.Paths import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt index cded046f..762ec487 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import android.content.Context import android.content.Intent diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt similarity index 99% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index b3edf24e..390c834d 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import android.content.Context import android.content.Intent @@ -27,7 +27,7 @@ import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer -import dev.krtirtho.spotube.media.PlaybackService +import org.spotube.spotube.media.PlaybackService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt similarity index 73% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt index d3f042d2..a6e66693 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt @@ -15,17 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di import android.content.Context -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.share.AndroidShareService -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.media.MediaBrowseHelper -import dev.krtirtho.spotube.modules.library.local_tracks.media.AndroidLocalMediaDiscoveryService -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.AndroidShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.media.MediaBrowseHelper +import org.spotube.spotube.modules.library.local_tracks.media.AndroidLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService import org.koin.dsl.module actual val platformModules = module { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt index 409b08d9..ba5f18c9 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.discovery +package org.spotube.spotube.core.discovery import android.Manifest import android.content.pm.PackageManager diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt index 0562a6fb..3676717e 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.i18n +package org.spotube.spotube.core.i18n import android.content.res.Configuration import android.content.res.Resources diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt index 20c187a0..dfee7da5 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths import android.content.Context import android.os.Environment diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/share/AndroidShareService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt similarity index 92% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/share/AndroidShareService.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt index 4624ff6d..91a7a02e 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/share/AndroidShareService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share import android.content.Context import androidx.core.app.ShareCompat -import dev.krtirtho.spotube.core.share.ShareService +import org.spotube.spotube.core.share.ShareService class AndroidShareService(private val context: Context) : ShareService { override fun share(url: String, title: String) { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt similarity index 94% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt index 52b71013..2c05aec3 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import android.webkit.CookieManager import android.webkit.WebStorage diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt similarity index 96% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt index b05bcc5e..3591c937 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.Executors diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt similarity index 95% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt index 060d6d50..c3ba32df 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt @@ -15,31 +15,31 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.media +package org.spotube.spotube.media import android.net.Uri import android.os.Bundle import android.util.Log import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.modules.album.AlbumRepository -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.home.HomeScreenRepository -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.playlist.PlaylistRepository -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository -import dev.krtirtho.spotube.modules.search.SearchRepository -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.home.HomeScreenRepository +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.modules.search.SearchRepository +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.flow.first import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt similarity index 96% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt index c4177b0e..4a6cd6f7 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.media +package org.spotube.spotube.media import android.annotation.SuppressLint import android.app.NotificationChannel @@ -40,11 +40,11 @@ import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture -import dev.krtirtho.spotube.MainActivity -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.MainActivity +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -371,20 +371,20 @@ class PlaybackService : MediaLibraryService(), KoinComponent { when { mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") -> { val playlistId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") - val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playPlaylist(playlistId) return } mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> { val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") - val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playAlbum(albumId) return } mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> { - val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playSavedTracks() return } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt similarity index 99% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt index f258bb9b..0b8eb37b 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import android.Manifest import android.content.Context @@ -26,7 +26,7 @@ import android.os.Environment import android.os.Handler import android.os.Looper import android.provider.MediaStore -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent import java.io.File import java.util.Locale diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt index e3fbc988..6b066575 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import android.Manifest import android.content.pm.PackageManager diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModek.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModek.kt deleted file mode 100644 index 6464c81f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModek.kt +++ /dev/null @@ -1,2 +0,0 @@ -package dev.krtirtho.spotube.modules.blacklist - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt deleted file mode 100644 index e2a68daa..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt +++ /dev/null @@ -1,3 +0,0 @@ -package dev.krtirtho.spotube.resources.iconsax - -object Iconsax diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt similarity index 83% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt index bcb8ba2e..9655a6d3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout @@ -33,42 +33,42 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay -import dev.krtirtho.spotube.core.DebugConfig -import dev.krtirtho.spotube.core.i18n.AppLocaleProvider -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES -import dev.krtirtho.spotube.core.navigation.navPopTransitionSpec -import dev.krtirtho.spotube.core.navigation.navPredictivePopTransitionSpec -import dev.krtirtho.spotube.core.navigation.navPushTransitionSpec -import dev.krtirtho.spotube.core.navigation.rememberNavigationState -import dev.krtirtho.spotube.core.navigation.toEntries -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme -import dev.krtirtho.spotube.modules.library.LibraryTab -import dev.krtirtho.spotube.modules.settings.SettingsRepository -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.shell.AppShell -import dev.krtirtho.spotube.modules.support.LocalSupportPrompt -import dev.krtirtho.spotube.modules.support.SupportDialog -import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis -import dev.krtirtho.spotube.modules.update.AppUpdateDialog -import dev.krtirtho.spotube.modules.update.AppUpdateService -import dev.krtirtho.spotube.modules.update.PluginUpdateDialog -import dev.krtirtho.spotube.modules.update.PluginUpdateService -import dev.krtirtho.spotube.modules.webview.WebViewScreen -import dev.krtirtho.spotube.modules.welcome.WelcomeRepository -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxHome -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.core.DebugConfig +import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.navPopTransitionSpec +import org.spotube.spotube.core.navigation.navPredictivePopTransitionSpec +import org.spotube.spotube.core.navigation.navPushTransitionSpec +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.core.navigation.toEntries +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.shell.AppShell +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.modules.support.SupportDialog +import org.spotube.spotube.modules.support.SupportReminderIntervalMillis +import org.spotube.spotube.modules.update.AppUpdateDialog +import org.spotube.spotube.modules.update.AppUpdateService +import org.spotube.spotube.modules.update.PluginUpdateDialog +import org.spotube.spotube.modules.update.PluginUpdateService +import org.spotube.spotube.modules.webview.WebViewScreen +import org.spotube.spotube.modules.welcome.WelcomeRepository +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxHome +import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular +import org.spotube.spotube.resources.iconsax.IconsaxMusicDashboard +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone +import org.spotube.spotube.resources.iconsax.User import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlin.time.Clock import org.koin.compose.koinInject @@ -253,7 +253,7 @@ fun App( onDismiss = appUpdateService::dismissUpdate, onIgnore = appUpdateService::ignoreUpdate, onUpdate = { - runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + runCatching { openUrlInBrowser("https://spotube.org/downloads/") } appUpdateService.dismissUpdate() }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt index 07948670..5207160f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube enum class PlatformType { Android, IOS, Windows, Linux, MacOS, Unknown diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt index 5be13cfb..b1e5941d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core +package org.spotube.spotube.core /** - * Development-only switches. These are intentionally kept out of [dev.krtirtho.spotube.modules.settings.UserSettings] + * Development-only switches. These are intentionally kept out of [org.spotube.spotube.modules.settings.UserSettings] * so they can never leak into user-facing configuration. */ object DebugConfig { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt index ecdf309b..7e67b35d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt index e52cbbfb..9d6e6a2d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.time.Duration diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt index a373952a..51d47129 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt index 91b03b26..2b03d8c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import androidx.datastore.preferences.core.edit -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.db.DatabaseKeys +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 95de3699..4fc6e165 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.plugin.PluginProvider -import dev.krtirtho.spotube.modules.settings.SettingsProvider +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.plugin.PluginProvider +import org.spotube.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt index 7b08d112..d8319f8a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.db +package org.spotube.spotube.core.db import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.paths.Paths import okio.FileSystem import okio.Path.Companion.toPath import okio.SYSTEM diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt index 00deb04d..c2ba2b1e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.deeplink +package org.spotube.spotube.core.deeplink /** * Cross-platform receiver for URIs handed to the app by the operating system diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/EnrichKoin.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/EnrichKoin.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt index 5a035274..7ae4266c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/EnrichKoin.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di import androidx.compose.runtime.Composable import co.touchlab.kermit.Logger diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Init.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Init.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt index 9b95c525..b0491bc4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Init.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di import co.touchlab.kermit.Logger import co.touchlab.kermit.koin.KermitKoinLogger diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt similarity index 60% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index b31bc300..bcc986dc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -15,82 +15,82 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository -import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService -import dev.krtirtho.spotube.core.discord.DiscordRpcService -import dev.krtirtho.spotube.core.jam.JamRoomClient -import dev.krtirtho.spotube.core.jam.JamRoomService -import dev.krtirtho.spotube.core.navigation.navigationModule -import dev.krtirtho.spotube.core.remote.RemoteControlClient -import dev.krtirtho.spotube.core.remote.RemoteControlHandler -import dev.krtirtho.spotube.core.remote.RemoteControlService -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.scrobble.ScrobbleManager -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.server.AlternativeTracksRepository -import dev.krtirtho.spotube.core.server.CacheManager -import dev.krtirtho.spotube.core.server.LocalServer -import dev.krtirtho.spotube.core.server.MatchedTracksRepository -import dev.krtirtho.spotube.core.server.TrackSourceRepository -import dev.krtirtho.spotube.core.server.StreamingUrlRepository -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.modules.album.AlbumRepository -import dev.krtirtho.spotube.modules.album.AlbumViewModel -import dev.krtirtho.spotube.modules.artist.ArtistRepository -import dev.krtirtho.spotube.modules.artist.ArtistViewModel -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel -import dev.krtirtho.spotube.modules.devices.DevicesViewModel -import dev.krtirtho.spotube.modules.devices.RemoteControlViewModel -import dev.krtirtho.spotube.modules.jam.JamViewModel -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.home.HomeScreenRepository -import dev.krtirtho.spotube.modules.home.HomeScreenViewModel -import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel -import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsViewModel -import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator -import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel -import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel -import dev.krtirtho.spotube.modules.playlist.PlaylistRepository -import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel -import dev.krtirtho.spotube.modules.plugin.AudioPluginSource -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.plugin.PluginProvider -import dev.krtirtho.spotube.modules.plugin.PluginViewModel -import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuardViewModel -import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.modules.search.SearchRepository -import dev.krtirtho.spotube.modules.search.SearchScreenViewModel -import dev.krtirtho.spotube.modules.update.AppUpdateService -import dev.krtirtho.spotube.modules.update.PluginUpdateService -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.krtirtho.spotube.modules.settings.SettingsRepository -import dev.krtirtho.spotube.modules.settings.UserSettingsSource -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel -import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel -import dev.krtirtho.spotube.modules.shell.AppShellViewModel -import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel -import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel -import dev.krtirtho.spotube.modules.welcome.WelcomeRepository -import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository +import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueStateRepository +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.discord.DiscordRpcService +import org.spotube.spotube.core.jam.JamRoomClient +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.navigationModule +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlHandler +import org.spotube.spotube.core.remote.RemoteControlService +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.scrobble.ScrobbleManager +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.server.AlternativeTracksRepository +import org.spotube.spotube.core.server.CacheManager +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.core.server.MatchedTracksRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.album.AlbumViewModel +import org.spotube.spotube.modules.artist.ArtistRepository +import org.spotube.spotube.modules.artist.ArtistViewModel +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.blacklist.BlacklistViewModel +import org.spotube.spotube.modules.devices.DevicesViewModel +import org.spotube.spotube.modules.devices.RemoteControlViewModel +import org.spotube.spotube.modules.jam.JamViewModel +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.home.HomeScreenRepository +import org.spotube.spotube.modules.home.HomeScreenViewModel +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.album.LibraryAlbumsViewModel +import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel +import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.playlist.PlaylistViewModel +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginProvider +import org.spotube.spotube.modules.plugin.PluginViewModel +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuardViewModel +import org.spotube.spotube.modules.plugin.ScrobblePluginSource +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.search.SearchRepository +import org.spotube.spotube.modules.search.SearchScreenViewModel +import org.spotube.spotube.modules.update.AppUpdateService +import org.spotube.spotube.modules.update.PluginUpdateService +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.modules.settings.UserSettingsSource +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.JamSettingsViewModel +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.shell.AppShellViewModel +import org.spotube.spotube.modules.shell.PlayerOptionsViewModel +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContentViewModel +import org.spotube.spotube.modules.welcome.WelcomeRepository +import org.spotube.spotube.modules.welcome.WelcomeViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.createdAtStart diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt index 9240a6bf..03942de1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.discord +package org.spotube.spotube.core.discord -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -202,7 +202,7 @@ class DiscordRpcService( private data class DiscordRpcState( val enabled: Boolean, val playerState: PlayerState, - val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, + val mediaItem: org.spotube.spotube.core.audioplayer.MediaItem?, val position: kotlin.time.Duration, val duration: kotlin.time.Duration) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt index b525682e..31d5c2d3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.discovery +package org.spotube.spotube.core.discovery import com.appstractive.dnssd.DiscoveryEvent import com.appstractive.dnssd.NetService diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt index 404c2b0d..da6da28b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.discovery +package org.spotube.spotube.core.discovery import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt index 87e08b95..77b50482 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.extras +package org.spotube.spotube.core.extras fun String.kebabToTitleCase(): String { return this.split("-").joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt index 60fbd612..58af88e5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.i18n +package org.spotube.spotube.core.i18n import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -23,7 +23,7 @@ import androidx.compose.runtime.ProvidedValue import androidx.compose.runtime.key import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection -import dev.krtirtho.spotube.modules.settings.SupportedLanguages +import org.spotube.spotube.modules.settings.SupportedLanguages /** * Points the platform locale (which Compose resources resolve strings from) at [languageTag], diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt index f007f635..8e268e38 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.jam +package org.spotube.spotube.core.jam -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.MediaItem -import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.QueueEntry import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -168,7 +168,7 @@ data class JamMediaItem( duration = kotlin.time.Duration.parse("${item.durationMs}ms"), coverURL = item.coverUrl, url = item.url, - protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol + protocol = org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol .valueOf(item.protocol.ifBlank { "PROGRESSIVE" })) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt index e36b974b..85085f0f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.jam +package org.spotube.spotube.core.jam import co.touchlab.kermit.Logger import com.ditchoom.buffer.Charset @@ -29,7 +29,7 @@ import com.ditchoom.mqtt.controlpacket.QualityOfService import com.ditchoom.mqtt.controlpacket.TopicName import com.ditchoom.mqtt.controlpacket.WillConfig import com.ditchoom.mqtt5.controlpacket.ConnectionRequest -import dev.krtirtho.spotube.modules.settings.JamBroker +import org.spotube.spotube.modules.settings.JamBroker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt index 0a1b891f..f8d0f19b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.jam +package org.spotube.spotube.core.jam import kotlin.random.Random diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt index 422b7b44..f9963e90 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.jam +package org.spotube.spotube.core.jam import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -481,7 +481,7 @@ class JamRoomService( return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" } } - private fun newClientId(broker: dev.krtirtho.spotube.modules.settings.JamBroker): String { + private fun newClientId(broker: org.spotube.spotube.modules.settings.JamBroker): String { val suffix = buildString(6) { val chars = "0123456789abcdef" repeat(6) { append(chars[Random.nextInt(chars.length)]) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt index 742ac190..d6483ffb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.navigation +package org.spotube.spotube.core.navigation import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationCommands.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationCommands.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt index 7041d557..4cd57fb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationCommands.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.navigation +package org.spotube.spotube.core.navigation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt index 88bdeaef..93c707c1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -15,27 +15,27 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.navigation +package org.spotube.spotube.core.navigation import androidx.navigation3.runtime.NavKey -import dev.krtirtho.spotube.modules.album.AlbumScreen -import dev.krtirtho.spotube.modules.artist.ArtistScreen -import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen -import dev.krtirtho.spotube.modules.devices.DevicesScreen -import dev.krtirtho.spotube.modules.devices.RemoteControlScreen -import dev.krtirtho.spotube.modules.jam.JamScreen -import dev.krtirtho.spotube.modules.home.HomeScreen -import dev.krtirtho.spotube.modules.library.LibraryScreen -import dev.krtirtho.spotube.modules.lyrics.LyricsScreen -import dev.krtirtho.spotube.modules.playlist.PlaylistScreen -import dev.krtirtho.spotube.modules.plugin.PluginScreen -import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen -import dev.krtirtho.spotube.modules.search.SearchScreen -import dev.krtirtho.spotube.modules.settings.SettingsScreen -import dev.krtirtho.spotube.modules.settings.JamSettingsScreen -import dev.krtirtho.spotube.modules.welcome.WelcomeScreen +import org.spotube.spotube.modules.album.AlbumScreen +import org.spotube.spotube.modules.artist.ArtistScreen +import org.spotube.spotube.modules.blacklist.BlacklistScreen +import org.spotube.spotube.modules.devices.DevicesScreen +import org.spotube.spotube.modules.devices.RemoteControlScreen +import org.spotube.spotube.modules.jam.JamScreen +import org.spotube.spotube.modules.home.HomeScreen +import org.spotube.spotube.modules.library.LibraryScreen +import org.spotube.spotube.modules.lyrics.LyricsScreen +import org.spotube.spotube.modules.playlist.PlaylistScreen +import org.spotube.spotube.modules.plugin.PluginScreen +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuard +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen +import org.spotube.spotube.modules.search.SearchScreen +import org.spotube.spotube.modules.settings.SettingsScreen +import org.spotube.spotube.modules.settings.JamSettingsScreen +import org.spotube.spotube.modules.welcome.WelcomeScreen import kotlinx.serialization.Serializable import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index e9e65bd9..bd35c0ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.navigation +package org.spotube.spotube.core.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -34,7 +34,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer import androidx.savedstate.serialization.SavedStateConfiguration -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import kotlinx.serialization.PolymorphicSerializer import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt index 817e89fd..8ca9471f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import kotlinx.serialization.Serializable @Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt index 880a587f..d624f10b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe enum class SearchKind { /** YouTube Music "Songs" catalogue. */ diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt index 17ac0708..07f002c0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") expect class Paths() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt index 01b4ea8d..6a425968 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.playback +package org.spotube.spotube.core.playback -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.album.AlbumRepository -import dev.krtirtho.spotube.modules.artist.ArtistRepository -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.playlist.PlaylistRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository -import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.artist.ArtistRepository +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.core.remote.RemoteCollectionType class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt index 311169db..b5667d1f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.remote +package org.spotube.spotube.core.remote import org.spotube.millennium.composables.MillenniumAlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt index 30bfa3b1..f4894ede 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.remote +package org.spotube.spotube.core.remote import co.touchlab.kermit.Logger import io.ktor.client.HttpClient diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt index f593f5ad..0fe817eb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.remote +package org.spotube.spotube.core.remote -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.PlayerState as AudioPlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.server.websocket.WebSocketServerSession import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt index dde77ef0..f3d95cde 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.remote +package org.spotube.spotube.core.remote -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt index 3d49aa31..c0c19ae6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.remote +package org.spotube.spotube.core.remote import co.touchlab.kermit.Logger import com.appstractive.dnssd.NetService -import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService -import dev.krtirtho.spotube.core.server.LocalServer -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt index 936a2855..d4442f15 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.remote +package org.spotube.spotube.core.remote import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt index b48408b2..6d58667d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.scrobble +package org.spotube.spotube.core.scrobble -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.ScrobblePluginSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt index fa70b004..9a4775a1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.AudioPluginSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.AudioPluginSource import org.koin.core.component.KoinComponent class AlternativeTracksRepository( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt index 01423b5f..28093b8a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.http.ContentType import io.ktor.http.Headers import io.ktor.http.HeadersBuilder diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index 26a57426..69c9ae48 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.remote.RemoteControlHandler -import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.RemoteControlHandler +import org.spotube.spotube.modules.settings.SettingsViewModel import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt index 8c006c5d..a93bb012 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.db.Database +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index cb52ae66..ec6ef668 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger import io.ktor.client.HttpClient import io.ktor.client.request.prepareRequest import io.ktor.client.request.request diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 7ff57906..3375f2d2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -15,19 +15,19 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.AudioPluginSource -import dev.krtirtho.spotube.modules.settings.UserSettingsSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +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.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettingsSource import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.component.KoinComponent diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/share/ShareService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/share/ShareService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt index d98d8ad2..9ff7ccb9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/share/ShareService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share interface ShareService { fun share(url: String, title: String) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt index 62700a4d..28a0674b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.tools.user_agents +package org.spotube.spotube.core.tools.user_agents import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.getPlatform +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.getPlatform import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt index 8b713c37..31a3fa05 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumDialog @@ -27,7 +27,7 @@ import org.spotube.millennium.composables.MillenniumDialogTitle import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle @@ -49,8 +49,8 @@ import androidx.compose.ui.Modifier import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.FluentDismiss -import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.FluentDismiss +import org.spotube.spotube.resources.iconsax.Iconsax import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index be7f2ea6..ed860c08 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.background @@ -62,12 +62,12 @@ import com.composeunstyled.SheetDetent import com.composeunstyled.rememberModalBottomSheetState import compose.icons.FeatherIcons import compose.icons.feathericons.Check -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxShare +import org.spotube.spotube.resources.iconsax.IconsaxTrash import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumDropdownMenuContent diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt index df7145a9..40694847 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -23,14 +23,14 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.remote.RemoteCollectionType -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.koin.compose.koinInject @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt index 97d7aa7b..74d7e83b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumIcon @@ -24,9 +24,9 @@ import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.RowScope import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 -import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.resources.iconsax.ArrowLeft3 +import org.spotube.spotube.resources.iconsax.Iconsax import org.koin.compose.koinInject import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt index 0c9d065c..c974f17c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.cards.AvatarCard import org.koin.compose.koinInject import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt index 07da835f..1ac2597e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -43,7 +43,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -53,17 +53,17 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle2 +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt index 5dbbaa16..678dfc5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.Box @@ -38,15 +38,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.composeunstyled.Text -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle2 import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.spotube.millennium.composables.MillenniumIcon @@ -145,7 +145,7 @@ fun CollectionView( state = listState, headerContent = { if (isLoading && tracks.isEmpty()) { - dev.krtirtho.spotube.core.ui.misc.SkeletonTree(true) { + org.spotube.spotube.core.ui.misc.SkeletonTree(true) { CollectionDetails( title = title.ifBlank { stringResource(Res.string.common_loading) }, description = description, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt index 2b35c5b6..753947e4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumDialogHeader @@ -45,13 +45,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel import org.koin.compose.viewmodel.koinViewModel import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt index 4ae55ebc..33ff15e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumIcon @@ -39,7 +39,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints @@ -66,21 +66,21 @@ import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.ui.coverflow.Coverflow -import dev.krtirtho.spotube.core.ui.coverflow.CoverflowParams -import dev.krtirtho.spotube.core.ui.coverflow.rememberCoverflowState -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle -import dev.krtirtho.spotube.resources.iconsax.Play +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.ui.coverflow.Coverflow +import org.spotube.spotube.core.ui.coverflow.CoverflowParams +import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle +import org.spotube.spotube.resources.iconsax.Play import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt index 4eacf176..33ec72c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumIcon @@ -33,7 +33,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable @@ -46,22 +46,22 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.Play +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.Play import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject import spotube.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource -import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile -import dev.krtirtho.spotube.core.ui.component.cards.MediaCardSurface +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.component.cards.MediaCardSurface import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.input.InputMode diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt index 59ab53e0..77a84021 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -38,7 +38,7 @@ import com.composeunstyled.Text import compose.icons.FeatherIcons import compose.icons.feathericons.Lock import compose.icons.feathericons.LogIn -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumIcon diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt similarity index 81% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt index 48e82d51..d29fa1c4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt @@ -15,21 +15,21 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.remote.RemoteCollectionType -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt index 60de716c..a37ef31b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt index 57cf1f4e..d1862b48 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope @@ -40,10 +40,10 @@ import coil3.compose.AsyncImage import compose.icons.FeatherIcons import compose.icons.feathericons.Play import compose.icons.feathericons.PlusSquare -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt index e8cf94de..e6e69dfc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component @@ -61,7 +61,7 @@ import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTriStateCheckbox -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -90,27 +90,27 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.isDesktop -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxSort +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlaylist +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxSort import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt index b2b2dadc..1b7217c9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumIcon @@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -45,21 +45,21 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxMusicCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlaylist +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxShare import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt index c42a5e34..5213cc0c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -38,8 +38,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.core.ui.component.cards.AvatarCard import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt index d677bdd3..33b63559 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.isDesktop +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop @Composable expect fun VerticalScrollbar( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt index db311800..a9593908 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,17 +27,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.User @Composable fun AvatarCard( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt index b4095530..fe656879 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt @@ -1,13 +1,13 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.User @Composable fun AvatarListTile( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt index 43809d5f..e291363f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt index 7cd95441..9af4fe34 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.millenniumBevelBrush diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt index bd79c9e0..c8b98a14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards import org.spotube.millennium.composables.MillenniumIcon @@ -37,7 +37,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,15 +47,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.core.ui.component.LocalAnimatedVisibilityScope +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.component.sharedElementOrNone +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_add_to_queue_content_description diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt index cff5697b..8b8edd2a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Arrangement @@ -22,17 +22,17 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.composeunstyled.LocalTextStyle import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.core.ui.component.LocalAnimatedVisibilityScope +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.component.sharedElementOrNone +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt index 1eafe443..4ff18b1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt index 72151f39..2b85d539 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.runtime.Stable import androidx.compose.ui.unit.IntSize diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt index 4ca74a4b..6b49fc94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt index c2a7bf5b..5efeac08 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.runtime.Immutable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt index e534c464..6a21e771 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt index 5e716f59..04acc272 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt index 008f788b..ca924c8e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt index 30954e17..6588d239 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.misc +package org.spotube.spotube.core.ui.misc import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt index 15f74e89..6720efb9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.theming +package org.spotube.spotube.core.ui.theming import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.PaddingValues @@ -34,8 +34,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import dev.krtirtho.spotube.modules.settings.Theme -import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings import org.spotube.millennium.composables.MillenniumModalBottomSheetTheme import org.spotube.millennium.composables.MillenniumTooltipHost import org.spotube.millennium.composables.MillenniumTopAppBarTheme diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt index b9880a35..b89c9ec8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import dev.nucleusframework.webview.web.WebContent import dev.nucleusframework.webview.web.WebViewNavigator import dev.nucleusframework.webview.cookie.CookieManager -import dev.krtirtho.plugin_interfaces.host_apis.Cookie -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes +import org.spotube.plugin_interfaces.host_apis.Cookie +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt index c67c8fd0..5dab45ed 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import dev.nucleusframework.webview.web.WebViewState diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt index 0b0596af..c6984344 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import org.spotube.millennium.composables.MillenniumIcon @@ -37,7 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.ui.draw.clip -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -54,8 +54,8 @@ import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronLeft import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.X -import dev.krtirtho.spotube.core.tools.user_agents.UserAgents -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.tools.user_agents.UserAgents +import org.spotube.spotube.core.ui.component.ApplicationMainBar import dev.nucleusframework.webview.jsbridge.IJsMessageHandler import dev.nucleusframework.webview.jsbridge.JsMessage import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt similarity index 81% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt index 80f5514b..b4a71234 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.spotube.core.zipline.plugin_apis.common.RealCoreAPI -import dev.krtirtho.spotube.core.zipline.plugin_apis.lrclib.RealLRCLibLyricsAPI -import dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt.RealNewPipeAudioAPI -import dev.krtirtho.spotube.modules.plugin.LRCLIB_BUILT_IN_PLUGIN -import dev.krtirtho.spotube.modules.plugin.NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -import dev.krtirtho.spotube.modules.plugin.PluginEntry +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.spotube.core.zipline.plugin_apis.common.RealCoreAPI +import org.spotube.spotube.core.zipline.plugin_apis.lrclib.RealLRCLibLyricsAPI +import org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt.RealNewPipeAudioAPI +import org.spotube.spotube.modules.plugin.LRCLIB_BUILT_IN_PLUGIN +import org.spotube.spotube.modules.plugin.NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN +import org.spotube.spotube.modules.plugin.PluginEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/FileSystemHTTPClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/FileSystemHTTPClient.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt index 784fae2e..a08da9a6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/FileSystemHTTPClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import app.cash.zipline.loader.ZiplineHttpClient -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import io.ktor.http.URLBuilder import io.ktor.http.decodeURLQueryComponent import okio.ByteString diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt similarity index 69% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt index 6db523e3..023415fe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt @@ -15,20 +15,20 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI import kotlinx.coroutines.flow.StateFlow import kotlin.reflect.KClass diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt index 14282692..8ee39b8f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import kotlinx.coroutines.CoroutineDispatcher diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt similarity index 79% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt index 42acea9f..9b02c9e4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import app.cash.zipline.Zipline import app.cash.zipline.ZiplineService @@ -23,50 +23,50 @@ import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh import app.cash.zipline.loader.LoadResult import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.ZiplineLoader -import dev.krtirtho.plugin_interfaces.core.Initializer -import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginCapability -import dev.krtirtho.spotube.modules.plugin.PluginEntry +import org.spotube.plugin_interfaces.core.Initializer +import org.spotube.plugin_interfaces.core.Initializer_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.CryptoAPI +import org.spotube.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.core.zipline.host_apis.RealCryptoAPI +import org.spotube.spotube.core.zipline.host_apis.RealHttpClientAPI +import org.spotube.spotube.core.zipline.host_apis.RealPersistedStorageAPI +import org.spotube.spotube.core.zipline.host_apis.RealSystemInformationAPI +import org.spotube.spotube.core.zipline.host_apis.RealWebViewAPI +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginCapability +import org.spotube.spotube.modules.plugin.PluginEntry import io.ktor.http.URLBuilder import io.ktor.http.decodeURLQueryComponent import kotlinx.coroutines.CoroutineExceptionHandler diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt index 1fd4ac7a..bac3d40a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt @@ -15,39 +15,39 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis -import dev.krtirtho.plugin_interfaces.host_apis.AESEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize.B128 -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize.B192 -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize.B256 -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI -import dev.krtirtho.plugin_interfaces.host_apis.GenerateKeyPairAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.P256 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.P384 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.P521 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.brainpoolP256r1 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.brainpoolP384r1 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.brainpoolP512r1 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.secp256k1 -import dev.krtirtho.plugin_interfaces.host_apis.ECDSASignatureFormats -import dev.krtirtho.plugin_interfaces.host_apis.ECEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.EdDSACurves -import dev.krtirtho.plugin_interfaces.host_apis.EdDSACurves.Ed25519 -import dev.krtirtho.plugin_interfaces.host_apis.EdDSACurves.Ed448 -import dev.krtirtho.plugin_interfaces.host_apis.EdDSAEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.MACKeyGeneratorAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.HMACEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.HashAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.LegacyCipherAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.RSAEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.RSAEncodingFormats.JWK -import dev.krtirtho.plugin_interfaces.host_apis.RSAEncodingFormats.PEM -import dev.krtirtho.plugin_interfaces.host_apis.SignAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.MACSignatureAlgorithms -import dev.krtirtho.spotube.core.zipline.host_apis.additionals.DES +import org.spotube.plugin_interfaces.host_apis.AESEncodingFormats +import org.spotube.plugin_interfaces.host_apis.AESKeySize +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B128 +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B192 +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B256 +import org.spotube.plugin_interfaces.host_apis.CryptoAPI +import org.spotube.plugin_interfaces.host_apis.GenerateKeyPairAlgorithms +import org.spotube.plugin_interfaces.host_apis.ECCurves +import org.spotube.plugin_interfaces.host_apis.ECCurves.P256 +import org.spotube.plugin_interfaces.host_apis.ECCurves.P384 +import org.spotube.plugin_interfaces.host_apis.ECCurves.P521 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP256r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP384r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP512r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.secp256k1 +import org.spotube.plugin_interfaces.host_apis.ECDSASignatureFormats +import org.spotube.plugin_interfaces.host_apis.ECEncodingFormats +import org.spotube.plugin_interfaces.host_apis.EdDSACurves +import org.spotube.plugin_interfaces.host_apis.EdDSACurves.Ed25519 +import org.spotube.plugin_interfaces.host_apis.EdDSACurves.Ed448 +import org.spotube.plugin_interfaces.host_apis.EdDSAEncodingFormats +import org.spotube.plugin_interfaces.host_apis.MACKeyGeneratorAlgorithms +import org.spotube.plugin_interfaces.host_apis.HMACEncodingFormats +import org.spotube.plugin_interfaces.host_apis.HashAlgorithms +import org.spotube.plugin_interfaces.host_apis.LegacyCipherAlgorithms +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats.JWK +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats.PEM +import org.spotube.plugin_interfaces.host_apis.SignAlgorithms +import org.spotube.plugin_interfaces.host_apis.MACSignatureAlgorithms +import org.spotube.spotube.core.zipline.host_apis.additionals.DES import dev.whyoleg.cryptography.BinarySize import dev.whyoleg.cryptography.BinarySize.Companion.bits import dev.whyoleg.cryptography.CryptographyAlgorithmId diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt index 5cd23546..a396aced 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpMethod -import dev.krtirtho.plugin_interfaces.host_apis.HttpResponse -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpMethod +import org.spotube.plugin_interfaces.host_apis.HttpResponse +import org.spotube.spotube.core.di.injectLogger import io.ktor.client.HttpClient import io.ktor.client.request.request import io.ktor.client.request.setBody diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt index 35148f46..72de1bc2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.modules.plugin.PluginEntry +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.modules.plugin.PluginEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.first diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt index 7fc8c10b..bd5c488f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI -import dev.krtirtho.spotube.getPlatform +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI +import org.spotube.spotube.getPlatform import kotlinx.datetime.TimeZone class RealSystemInformationAPI: SystemInformationAPI { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt index 830cb4cc..cbb4e1e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis -import dev.krtirtho.plugin_interfaces.host_apis.Cookie -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.spotube.core.webview.WebViewController +import org.spotube.plugin_interfaces.host_apis.Cookie +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.spotube.core.webview.WebViewController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DES.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DES.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt index d5cc1cef..e64edb94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DES.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis.additionals +package org.spotube.spotube.core.zipline.host_apis.additionals -import dev.krtirtho.plugin_interfaces.host_apis.PaddingTypes -import dev.krtirtho.plugin_interfaces.host_apis.SymmetricModes +import org.spotube.plugin_interfaces.host_apis.PaddingTypes +import org.spotube.plugin_interfaces.host_apis.SymmetricModes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt index ba8c7097..fccbd324 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.common +package org.spotube.spotube.core.zipline.plugin_apis.common -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt index 33667956..b46bc9e0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.lrclib +package org.spotube.spotube.core.zipline.plugin_apis.lrclib -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsResponse -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt index 3c63c55b..c9c95cca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index d10aa317..a1c80553 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.tools.user_agents.UserAgents +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.tools.user_agents.UserAgents import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index 788255b3..46174a37 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.newpipe.NewPipeService -import dev.krtirtho.spotube.core.newpipe.SearchKind -import dev.krtirtho.spotube.core.newpipe.VideoSearchResult +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.newpipe.NewPipeService +import org.spotube.spotube.core.newpipe.SearchKind +import org.spotube.spotube.core.newpipe.VideoSearchResult import org.koin.core.component.KoinComponent class RealNewPipeAudioAPI : AudioAPI, KoinComponent { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt index 36ff187c..636c8a41 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.album +package org.spotube.spotube.modules.album -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt index 3aa949b8..50d690f2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt @@ -15,22 +15,22 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.album +package org.spotube.spotube.modules.album import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.map -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService import org.koin.compose.koinInject -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.album_loading_title diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt index f1c8476a..0ba58bd4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt @@ -15,26 +15,26 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.album +package org.spotube.spotube.modules.album import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.remote.RemoteCollectionType -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt index f51cc187..b421fee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.artist +package org.spotube.spotube.modules.artist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt index 992cae40..30bda22f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.artist +package org.spotube.spotube.modules.artist import org.spotube.millennium.composables.MillenniumIcon @@ -46,7 +46,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable @@ -63,32 +63,32 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes import org.koin.compose.koinInject -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.TrackList -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.component.dragScrollable -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackList +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.User import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlin.math.roundToInt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt index a508f193..9f915280 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt @@ -15,27 +15,27 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.artist +package org.spotube.spotube.modules.artist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.remote.RemoteCollectionType -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt index 1a87bd0d..394d264b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.blacklist +package org.spotube.spotube.modules.blacklist import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.db.Database +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt index 6cf3eaca..dac469e7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.blacklist +package org.spotube.spotube.modules.blacklist import org.spotube.millennium.composables.MillenniumIcon @@ -37,7 +37,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -51,17 +51,17 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled -import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxMusicCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.User import org.koin.compose.viewmodel.koinViewModel import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt new file mode 100644 index 00000000..be47579b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt @@ -0,0 +1,2 @@ +package org.spotube.spotube.modules.blacklist + diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt index e7ff5210..a4b1ae81 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.blacklist +package org.spotube.spotube.modules.blacklist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt index e494ff27..ea92bd68 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.devices +package org.spotube.spotube.modules.devices import org.spotube.millennium.composables.MillenniumSpokeSpinner @@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -47,15 +47,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.discovery.DiscoveredDevice -import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.remote.ConnectionState -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.core.discovery.DiscoveredDevice +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt index 8e5089e3..65f59b5c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -15,20 +15,20 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.devices +package org.spotube.spotube.modules.devices import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService -import dev.krtirtho.spotube.core.discovery.DiscoveredDevice -import dev.krtirtho.spotube.core.discovery.DiscoveryState -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.remote.ConnectionState -import dev.krtirtho.spotube.core.remote.RemoteControlClient -import dev.krtirtho.spotube.core.remote.RemoteControlService -import dev.krtirtho.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.discovery.DiscoveredDevice +import org.spotube.spotube.core.discovery.DiscoveryState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlService +import org.spotube.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt index 95554726..849b7c5c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.devices +package org.spotube.spotube.modules.devices import org.spotube.millennium.composables.MillenniumDialogHeader @@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -40,13 +40,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.remote.ConnectionState -import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction -import dev.krtirtho.spotube.core.remote.RemoteControlClient -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt index fe3bc3be..bf67dcc8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.devices +package org.spotube.spotube.modules.devices import org.spotube.millennium.composables.MillenniumDropdownMenuItem -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle @@ -50,7 +50,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable @@ -68,22 +68,22 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.remote.ConnectionState -import dev.krtirtho.spotube.core.remote.RemoteQueueEntry -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle -import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteQueueEntry +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.modules.shell.player_queue.QueueSheet +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt index 7c6f1557..9b4c0896 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.devices +package org.spotube.spotube.modules.devices import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.core.remote.ConnectionState -import dev.krtirtho.spotube.core.remote.RemoteControlClient -import dev.krtirtho.spotube.core.remote.RemoteControlCommand -import dev.krtirtho.spotube.core.remote.RemoteControlEvent -import dev.krtirtho.spotube.core.remote.RemoteQueueEntry +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlCommand +import org.spotube.spotube.core.remote.RemoteControlEvent +import org.spotube.spotube.core.remote.RemoteQueueEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index 4c7a49e0..d2261217 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.downloads +package org.spotube.spotube.modules.downloads import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.server.StreamInfo -import dev.krtirtho.spotube.core.server.StreamingUrlRepository -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.server.StreamInfo +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt index 9168226f..4045940c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.downloads +package org.spotube.spotube.modules.downloads import org.spotube.millennium.composables.MillenniumIcon @@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -40,7 +40,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.koin.compose.viewmodel.koinViewModel @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt index 0cfa15c7..8d9c610c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.downloads +package org.spotube.spotube.modules.downloads import org.spotube.millennium.composables.MillenniumStripedProgress import androidx.compose.foundation.layout.Arrangement @@ -26,16 +26,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork -import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.core.ui.component.cards.MediaArtwork +import org.spotube.spotube.core.ui.component.cards.MediaEmptyState +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxTrash import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.spotube.millennium.composables.MillenniumButton diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt index a1b87a73..68ab8d7d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.downloads +package org.spotube.spotube.modules.downloads import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt index 15506867..f3e32ebd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.home +package org.spotube.spotube.modules.home import org.spotube.millennium.composables.MillenniumIcon @@ -39,7 +39,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import org.spotube.millennium.composables.MillenniumPullToRefreshBox import androidx.compose.runtime.Composable @@ -57,31 +57,31 @@ import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.currentWindowWidthBreakpoint -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.TrackCard -import dev.krtirtho.spotube.core.ui.component.UserCard -import dev.krtirtho.spotube.core.ui.component.FeaturedCarousel -import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.component.dragScrollable -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.modules.support.LocalSupportPrompt -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackCard +import org.spotube.spotube.core.ui.component.UserCard +import org.spotube.spotube.core.ui.component.FeaturedCarousel +import org.spotube.spotube.core.ui.component.VerticalScrollbar +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt index 5255c8dc..8c981e73 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.home +package org.spotube.spotube.modules.home -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt index 297e64e2..6e0e1397 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt @@ -15,17 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.home +package org.spotube.spotube.modules.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.PluginManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt index 7fa57b17..3ae9a7a2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.jam +package org.spotube.spotube.modules.jam import org.spotube.millennium.composables.MillenniumDropdownMenuItem -import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove -import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.IconsaxForbidden import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -58,13 +58,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.spotube.millennium.composables.MillenniumButton diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt index 2748f093..2d89c67d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -15,20 +15,20 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.jam +package org.spotube.spotube.modules.jam import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.jam.JamParticipant -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomCode -import dev.krtirtho.spotube.core.jam.JamRoomService -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomCode +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -109,7 +109,7 @@ class JamViewModel( val isConnected = values[2] as Boolean val roomCode = values[3] as String? val connectionError = values[4] as String? - val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings + val settings = values[5] as? org.spotube.spotube.modules.settings.UserSettings val localError = values[6] as String? val busy = values[8] as Boolean val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt index 357bad74..15a5ec0a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.jam +package org.spotube.spotube.modules.jam import org.spotube.millennium.composables.MillenniumAlertDialog -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt index 374ee5ab..467881d8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -20,8 +20,8 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import kotlinx.coroutines.flow.distinctUntilChanged /** Only reports viewport geometry. The owning ViewModel decides when to load another page. */ diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt index 74a880ab..7fb8bd0f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt index f3b75d96..f7b7bfc0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -43,21 +43,21 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text import com.composeunstyled.currentWindowWidthBreakpoint -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.isDesktop -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.modules.downloads.DownloadsScreen -import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen -import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen -import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen -import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.downloads.DownloadsScreen +import org.spotube.spotube.modules.library.album.LibraryAlbumsScreen +import org.spotube.spotube.modules.library.artist.LibraryArtistsScreen +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksScreen +import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsScreen +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken import org.jetbrains.compose.resources.StringResource -import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuard import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.spotube.millennium.adaptive.WindowBreakpoints diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt index 4e7effbf..aa703732 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt index 6027cc2c..c2e9aa22 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library import androidx.compose.ui.graphics.vector.ImageVector -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusicDashboard +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.StringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt index 7bc5bdaa..572f31e9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.library.album +package org.spotube.spotube.modules.library.album import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -12,12 +12,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.modules.library.LibraryMediaCollection -import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt index e2f9d7ee..a373f98d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.album +package org.spotube.spotube.modules.library.album import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.LibraryTab +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt index 06b0ef94..1ab6c08f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.library.artist +package org.spotube.spotube.modules.library.artist import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -12,12 +12,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard -import dev.krtirtho.spotube.modules.library.LibraryMediaCollection -import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt index 694d3595..01608629 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.artist +package org.spotube.spotube.modules.library.artist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.LibraryTab +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 4c22b818..d7bcb401 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.library.local_tracks +package org.spotube.spotube.modules.library.local_tracks import org.spotube.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumSpokeSpinner @@ -33,18 +33,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight -import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork -import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState -import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState -import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel -import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.core.ui.component.cards.MediaArtwork +import org.spotube.spotube.core.ui.component.cards.MediaEmptyState +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersDialogHost +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen +import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.pluralStringResource import org.koin.compose.viewmodel.koinViewModel diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index 84b14cff..706052da 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -15,17 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks +package org.spotube.spotube.modules.library.local_tracks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.LibraryTab -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -33,8 +33,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.getPlatform +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.library_local_refresh_failed diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt index 3442d69a..44775f22 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.datastore.preferences.core.edit -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.db.DatabaseKeys +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt index ecf551f9..9de2054d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media typealias LocalMediaChangeCallback = () -> Unit diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt index 1ca25812..9b0f1740 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt index 19e393ec..f2d17713 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt index 95820144..e6ad9ee6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt index 72065609..1f22bca0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt index 7dff7fc1..c2c7cd5e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media class NoopLocalMediaDiscoveryService : LocalMediaDiscoveryService { override suspend fun discoverFolders(roots: List): List { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt index 62819b97..09e3a729 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.playlist +package org.spotube.spotube.modules.library.playlist -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle @@ -39,7 +39,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState @@ -61,10 +61,10 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 56ad4381..cb652364 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.library.playlist +package org.spotube.spotube.modules.library.playlist import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,13 +13,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.component.LikedTracksCard -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.modules.library.LibraryMediaCollection -import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.LikedTracksCard +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 882b9d2b..159fbd04 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.playlist +package org.spotube.spotube.modules.library.playlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.LibraryTab +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt index 38c4eb9b..47e82f01 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.playlist +package org.spotube.spotube.modules.library.playlist -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import androidx.compose.foundation.clickable import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle @@ -38,7 +38,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt index fcec5cef..e9df67d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.lyrics +package org.spotube.spotube.modules.lyrics import org.spotube.millennium.composables.MillenniumBarsLoader @@ -53,7 +53,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -64,10 +64,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.ui.component.ApplicationBackButton +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt index 915d2367..41c7654a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.lyrics +package org.spotube.spotube.modules.lyrics import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.plugin.PluginManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt index c204d8ef..7d5133ea 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.lyrics +package org.spotube.spotube.modules.lyrics import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.layout.BoxWithConstraints @@ -53,8 +53,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.composeunstyled.Text -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.millennium.theme.MillenniumTheme private const val LYRICS_BLANK_LINE = "\u266A" diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index f50481df..c5f10295 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.playlist +package org.spotube.spotube.modules.playlist -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumIcon @@ -42,7 +42,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState @@ -64,13 +64,13 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.search.SearchRepository -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.search.SearchRepository +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxSearch import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt index c58da096..5aa1977d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.playlist +package org.spotube.spotube.modules.playlist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt index a80c867c..ececcd31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.playlist +package org.spotube.spotube.modules.playlist import androidx.compose.ui.Alignment @@ -37,20 +37,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.map -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService import org.koin.compose.koinInject -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker -import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData -import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.modules.library.playlist.PlaylistFormData +import org.spotube.spotube.modules.library.playlist.PlaylistFormSheet +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.playlist_add_tracks_button diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt index 589e13cf..af4d5686 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt @@ -15,27 +15,27 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.playlist +package org.spotube.spotube.modules.playlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.remote.RemoteCollectionType -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.core.ui.component.TrackOptionsState -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.core.ui.component.TrackOptionsState +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt index 532bc9ff..b933e110 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin import org.jetbrains.compose.resources.DrawableResource import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt index 86cfe2a1..34d1fd92 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin import io.ktor.client.HttpClient import io.ktor.client.call.body diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt index 1d7db0c4..0350ba14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -56,7 +56,7 @@ class MetadataAuthViewModel( MetadataAuthUiState(loginRequired = loginRequired, pluginName = name) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) - private fun loginRequiredFlow(service: dev.krtirtho.spotube.core.zipline.PluginService): Flow = flow { + private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow = flow { val requiresAuth = try { service.use { coreAPI.requiresAuthentication } } catch (e: Exception) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 557c0710..81f1ae84 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.db.DatabaseKeys -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.zipline.BuiltInPluginService -import dev.krtirtho.spotube.core.zipline.PluginService -import dev.krtirtho.spotube.core.zipline.ZiplinePluginService +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.zipline.BuiltInPluginService +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.ZiplinePluginService import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.bodyAsChannel diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt index e8ed2f06..2048248f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin import com.goncalossilva.murmurhash.MurmurHash3 import kotlinx.serialization.SerialName diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt index ec1a862f..0c969bfe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin import org.spotube.millennium.composables.MillenniumCircularProgress @@ -55,7 +55,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -77,25 +77,25 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.extras.kebabToTitleCase -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid -import dev.krtirtho.spotube.modules.plugin.components.PluginCard -import dev.krtirtho.spotube.modules.plugin.components.PluginDialogActions -import dev.krtirtho.spotube.modules.plugin.components.PluginDialogBody -import dev.krtirtho.spotube.modules.plugin.components.PluginDialogTitle -import dev.krtirtho.spotube.modules.plugin.components.PluginIconTile -import dev.krtirtho.spotube.modules.plugin.components.PluginLogoTile -import dev.krtirtho.spotube.modules.plugin.components.PluginPanel -import dev.krtirtho.spotube.modules.plugin.components.PluginPill -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.extras.kebabToTitleCase +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.HeaderDisplayMode +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.plugin.components.DefaultAbilityPluginGrid +import org.spotube.spotube.modules.plugin.components.PluginCard +import org.spotube.spotube.modules.plugin.components.PluginDialogActions +import org.spotube.spotube.modules.plugin.components.PluginDialogBody +import org.spotube.spotube.modules.plugin.components.PluginDialogTitle +import org.spotube.spotube.modules.plugin.components.PluginIconTile +import org.spotube.spotube.modules.plugin.components.PluginLogoTile +import org.spotube.spotube.modules.plugin.components.PluginPanel +import org.spotube.spotube.modules.plugin.components.PluginPill +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.User import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight import spotube.composeapp.generated.resources.plugin_install_sheet_subtitle @@ -103,26 +103,26 @@ import spotube.composeapp.generated.resources.plugin_install_file_hint import spotube.composeapp.generated.resources.plugin_install_or import spotube.composeapp.generated.resources.plugin_info_dialog_title import spotube.composeapp.generated.resources.plugin_version_label -import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog -import dev.krtirtho.spotube.modules.plugin.components.PluginLogo -import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog -import dev.krtirtho.spotube.modules.plugin.components.abilityIcon -import dev.krtirtho.spotube.modules.plugin.components.accentColor -import dev.krtirtho.spotube.modules.plugin.components.displayLabel -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.openUrlInBrowser -import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxBox -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit -import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxLink +import org.spotube.spotube.modules.plugin.components.PluginInstallDialog +import org.spotube.spotube.modules.plugin.components.PluginLogo +import org.spotube.spotube.modules.plugin.components.PluginPermissionDialog +import org.spotube.spotube.modules.plugin.components.abilityIcon +import org.spotube.spotube.modules.plugin.components.accentColor +import org.spotube.spotube.modules.plugin.components.displayLabel +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.openUrlInBrowser +import org.spotube.spotube.resources.iconsax.CarbonGithubLogo +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAdd +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxBox +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxExportArrowBulk +import org.spotube.spotube.resources.iconsax.IconsaxGlobe +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2Bulk +import org.spotube.spotube.resources.iconsax.IconsaxLink import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import okio.Path diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt index 3e04d224..510d04e8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.core.zipline.PluginService +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.core.zipline.PluginService import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.CancellationException diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt index d0e1e0e7..17f267d8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.components +package org.spotube.spotube.modules.plugin.components import org.spotube.millennium.composables.MillenniumIcon @@ -35,7 +35,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -51,18 +51,18 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem -import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode -import dev.krtirtho.spotube.modules.plugin.AbilitySelection -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxSound -import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.core.ui.component.HeaderDisplayMode +import org.spotube.spotube.modules.plugin.AbilitySelection +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxDocumentText +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxSound +import org.spotube.spotube.resources.iconsax.IconsaxTextalignLeft import okio.Path import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt index 9fce3eb4..0828cced 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.components +package org.spotube.spotube.modules.plugin.components import org.spotube.millennium.composables.MillenniumButton @@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -47,17 +47,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import okio.Path -import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBox -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxTag -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.modules.plugin.BUILT_IN_PLUGINS +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBox +import org.spotube.spotube.resources.iconsax.IconsaxCheckSquare +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxTag +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_action_remove diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginDialogParts.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginDialogParts.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt index dd114789..c86c4e9d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginDialogParts.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.components +package org.spotube.spotube.modules.plugin.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -42,11 +42,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxInformation import okio.Path import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt index e8eebdef..6a05841d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.components +package org.spotube.spotube.modules.plugin.components import org.spotube.millennium.composables.MillenniumCircularProgress import androidx.compose.foundation.layout.Arrangement @@ -49,12 +49,12 @@ import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.plugin.GitHubRelease -import dev.krtirtho.spotube.modules.plugin.GitHubRepo -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.GitHubRelease +import org.spotube.spotube.modules.plugin.GitHubRepo +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt index daffad26..531f99cd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.components +package org.spotube.spotube.modules.plugin.components import androidx.compose.foundation.Image import androidx.compose.runtime.Composable @@ -25,8 +25,8 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.modules.plugin.builtInPluginLogo +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.modules.plugin.builtInPluginLogo import okio.Path import org.jetbrains.compose.resources.painterResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt index bf2754d9..750448e7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.components +package org.spotube.spotube.modules.plugin.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -32,16 +32,16 @@ import androidx.compose.ui.unit.dp import com.composeunstyled.Text import compose.icons.FeatherIcons import compose.icons.feathericons.ArrowRight -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.plugin.PluginCapability -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxEye -import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare -import dev.krtirtho.spotube.resources.iconsax.PhosphorDatabaseDuotone -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginCapability +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxEye +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare +import org.spotube.spotube.resources.iconsax.PhosphorDatabaseDuotone +import org.spotube.spotube.resources.iconsax.User import okio.Path import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumIcon diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt index 3c501b4a..5f37592d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.guard +package org.spotube.spotube.modules.plugin.guard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -42,16 +42,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.LoginRequiredDisplay -import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.LoginRequiredDisplay +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAdd +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt similarity index 85% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt index f9346ef2..02cdf6cb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt @@ -15,17 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.guard +package org.spotube.spotube.modules.plugin.guard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.LibraryTab -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.plugin.PluginManagerStates +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginManagerStates import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt index e5a7a2e9..9b931d99 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.saved_tracks +package org.spotube.spotube.modules.saved_tracks -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt index 2eb73d78..b6bf6425 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -15,23 +15,23 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.saved_tracks +package org.spotube.spotube.modules.saved_tracks import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.map -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService import org.koin.compose.koinInject -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.liked_tracks import org.jetbrains.compose.resources.pluralStringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt index c8bdb3dd..2a947179 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.saved_tracks +package org.spotube.spotube.modules.saved_tracks import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,20 +28,20 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.remote.RemoteCollectionType -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt index 65af0b10..19d61d20 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.search +package org.spotube.spotube.modules.search -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt index 7352a399..03227191 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.search +package org.spotube.spotube.modules.search import org.spotube.millennium.composables.MillenniumMarqueeProgress @@ -61,7 +61,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.rememberTextFieldState -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -81,44 +81,44 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.currentWindowWidthBreakpoint -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.Platform -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.TrackList -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsState -import dev.krtirtho.spotube.core.ui.component.UserCard -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.component.dragScrollable -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.isDesktop -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.InconsaxClock +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.Platform +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackList +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsState +import org.spotube.spotube.core.ui.component.UserCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.InconsaxClock import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.search_clear_history diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt index 93ce9c70..d1a8d836 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt @@ -15,22 +15,22 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.search +package org.spotube.spotube.modules.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt index eeaaf81e..d7810185 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,21 +17,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard -import dev.krtirtho.spotube.modules.settings.sections.SettingsItemIcon -import dev.krtirtho.spotube.modules.settings.sections.settingsSectionCard -import dev.krtirtho.spotube.modules.settings.sections.settingsSectionHeader -import dev.krtirtho.spotube.resources.iconsax.CustomServer -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxEye -import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation -import dev.krtirtho.spotube.resources.iconsax.IconsaxTag -import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare -import dev.krtirtho.spotube.resources.iconsax.User -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.settingsSectionCard +import org.spotube.spotube.modules.settings.sections.settingsSectionHeader +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEye +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxTag +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare +import org.spotube.spotube.resources.iconsax.User +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt index ad66d86a..64a96ce2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt @@ -1,8 +1,8 @@ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.jam.JamRoomClient +import org.spotube.spotube.core.jam.JamRoomClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt index 8dbc4973..5368d6d0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt index 9db74b04..f7c2cd50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.compose.ui.graphics.Color import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index a43443f8..9a50178f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.spotube.millennium.theme.MillenniumPalette diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt index 8313d990..a69fd159 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.spotube.core.db.Database +import org.spotube.spotube.core.db.Database import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json /** Narrow read-only view over the current [UserSettings], for consumers (like - * [dev.krtirtho.spotube.core.server.StreamingUrlRepository]) that only need to read the + * [org.spotube.spotube.core.server.StreamingUrlRepository]) that only need to read the * user's streaming preferences without depending on the full [SettingsRepository]. */ interface UserSettingsSource { val userSettings: StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt index 68b23df1..e79ebe0b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import com.composeunstyled.currentWindowWidthBreakpoint @@ -35,24 +35,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.sections.appearanceSection -import dev.krtirtho.spotube.modules.settings.sections.cacheSection -import dev.krtirtho.spotube.modules.settings.sections.desktopSection -import dev.krtirtho.spotube.modules.settings.sections.downloadsSection -import dev.krtirtho.spotube.modules.settings.sections.jamSection -import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection -import dev.krtirtho.spotube.modules.settings.sections.playbackSection -import dev.krtirtho.spotube.modules.settings.sections.pluginsSection -import dev.krtirtho.spotube.modules.settings.sections.supportSection -import dev.krtirtho.spotube.modules.settings.sections.updatesSection -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.modules.settings.sections.appearanceSection +import org.spotube.spotube.modules.settings.sections.cacheSection +import org.spotube.spotube.modules.settings.sections.desktopSection +import org.spotube.spotube.modules.settings.sections.downloadsSection +import org.spotube.spotube.modules.settings.sections.jamSection +import org.spotube.spotube.modules.settings.sections.languageRegionSection +import org.spotube.spotube.modules.settings.sections.playbackSection +import org.spotube.spotube.modules.settings.sections.pluginsSection +import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.updatesSection +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt index 1316530f..3ff64bac 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import org.spotube.millennium.composables.MillenniumCircularProgress @@ -42,7 +42,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -59,35 +59,35 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.modules.blacklist.BlacklistContent -import dev.krtirtho.spotube.modules.plugin.PluginScreenContent -import dev.krtirtho.spotube.modules.settings.sections.appearanceSection -import dev.krtirtho.spotube.modules.settings.sections.cacheSection -import dev.krtirtho.spotube.modules.settings.sections.desktopSection -import dev.krtirtho.spotube.modules.settings.sections.downloadsSection -import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection -import dev.krtirtho.spotube.modules.settings.sections.playbackSection -import dev.krtirtho.spotube.modules.settings.sections.supportSection -import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle -import dev.krtirtho.spotube.modules.settings.sections.updatesSection -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload -import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation -import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.modules.blacklist.BlacklistContent +import org.spotube.spotube.modules.plugin.PluginScreenContent +import org.spotube.spotube.modules.settings.sections.appearanceSection +import org.spotube.spotube.modules.settings.sections.cacheSection +import org.spotube.spotube.modules.settings.sections.desktopSection +import org.spotube.spotube.modules.settings.sections.downloadsSection +import org.spotube.spotube.modules.settings.sections.languageRegionSection +import org.spotube.spotube.modules.settings.sections.playbackSection +import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.SettingsPageTitle +import org.spotube.spotube.modules.settings.sections.updatesSection +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxColorSwatch +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2 +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxLanguageSquare +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt index 42af007c..8fd6741a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt index ba54f3bf..83c49be7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.components +package org.spotube.spotube.modules.settings.components import org.spotube.millennium.composables.MillenniumDialogHeader @@ -43,7 +43,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.input.rememberTextFieldState -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -55,10 +55,10 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 import kotlinx.coroutines.flow.drop import spotube.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt index 1e3ae674..c6b61799 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.settings.components +package org.spotube.spotube.modules.settings.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,7 +20,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.path import org.spotube.millennium.composables.MillenniumDialog @@ -33,15 +33,15 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.settings.LocalMediaFolderRowUi -import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.core.ui.component.VerticalScrollbar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.LocalMediaFolderRowUi +import org.spotube.spotube.modules.settings.LocalMediaFoldersUiState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen +import org.spotube.spotube.resources.iconsax.IconsaxTrash import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt index 17c6d8f9..2ca288b0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.components +package org.spotube.spotube.modules.settings.components -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt index b3d4d0e6..9b7ba138 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumDialogHeader import org.spotube.millennium.composables.MillenniumDialogTitle @@ -48,15 +48,15 @@ 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 dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.Theme -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch -import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxColorSwatch +import org.spotube.spotube.resources.iconsax.IconsaxMagic import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt index 581d4cdd..53129924 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumIcon @@ -28,16 +28,16 @@ import compose.icons.FeatherIcons import compose.icons.feathericons.Folder import compose.icons.feathericons.HardDrive import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxStopCircle +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxFolder +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.resources.iconsax.IconsaxStopCircle import io.github.vinceglb.filekit.path import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt index 0874de79..ad3dc2e7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2 -import dev.krtirtho.spotube.resources.iconsax.LineDiscord +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2 +import org.spotube.spotube.resources.iconsax.LineDiscord import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.desktopSection( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt index 6e2b5dd8..37b7bf71 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumButton @@ -27,20 +27,20 @@ import androidx.compose.foundation.lazy.LazyListScope import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel -import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersDialogHost import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFolder +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.path import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt similarity index 81% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt index 1c8886de..53192bff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt @@ -7,21 +7,21 @@ * (at your option) any later version. */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.resources.iconsax.CustomServer -import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt index 37adca6b..ac3c95f0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.modules.settings.CountryCode -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.SupportedLanguages -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe -import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare +import org.spotube.spotube.modules.settings.CountryCode +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.SupportedLanguages +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxGlobe +import org.spotube.spotube.resources.iconsax.IconsaxLanguageSquare import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.settings_country_subtitle_current diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt index 71b77ad6..47d0e51f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight @@ -31,22 +31,22 @@ import compose.icons.feathericons.Repeat import compose.icons.feathericons.Server import compose.icons.feathericons.Sliders import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard -import dev.krtirtho.spotube.resources.iconsax.CustomServer -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit -import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow -import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow +import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.playbackSection( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt similarity index 85% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt index e4a44a6c..d43a1ca9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt @@ -15,25 +15,25 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.Package -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.pluginsSection( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt index d1482005..6d54f4de 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt @@ -15,17 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.support.donationOptions -import dev.krtirtho.spotube.modules.support.openDonation -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.support.donationOptions +import org.spotube.spotube.modules.support.openDonation +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.settings_section_support diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt similarity index 85% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt index 04b853dd..dbf5af6d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshArrow2 +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxRefreshArrow2 import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.settings_section_updates diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt index dbbe2b26..ab59ce60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import org.spotube.millennium.composables.MillenniumIcon @@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -41,8 +41,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt index 113642fb..9dc42cc4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import com.composeunstyled.LocalTextStyle import com.composeunstyled.Text @@ -24,13 +24,13 @@ import org.spotube.millennium.composables.MillenniumBottomNavigationItem import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.krtirtho.spotube.NavigationItem +import org.spotube.spotube.NavigationItem import org.jetbrains.compose.resources.stringResource -import dev.krtirtho.spotube.bottombarTabs -import dev.krtirtho.spotube.core.navigation.NavigationState -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator +import org.spotube.spotube.bottombarTabs +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.downloads.DownloadBadgeIndicator @Composable fun AppBottombar( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt index 9afbafcd..ddbdd2b5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import org.spotube.millennium.composables.MillenniumIcon @@ -46,7 +46,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.millennium.composables.MillenniumSnackbarHost import org.spotube.millennium.composables.MillenniumSnackbarHostState import org.spotube.millennium.composables.MillenniumIconButton @@ -82,41 +82,41 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon -import dev.krtirtho.spotube.modules.downloads.DownloadStatus -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel -import dev.krtirtho.spotube.modules.lyrics.SyncedLyricsList -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle -import dev.krtirtho.spotube.resources.iconsax.InconsaxClock -import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.downloads.DownloadProgressIcon +import org.spotube.spotube.modules.downloads.DownloadStatus +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.lyrics.SyncedLyricsList +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxArrowSquareUp +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.InconsaxClock +import org.spotube.spotube.resources.iconsax.SwapHorizontal2 import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt index 420234b8..9f9ebca4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import org.spotube.millennium.composables.MillenniumIcon @@ -41,7 +41,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope @@ -52,13 +52,13 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxMusic import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_play_content_description @@ -68,7 +68,7 @@ import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface internal val FloatingPlayerContentHeight = 64.dp internal val FloatingPlayerBottomBleed = 16.dp diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt index 09f28848..25f016cd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import org.spotube.millennium.composables.MillenniumDropdownMenuItem -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxMoon -import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxMoon +import org.spotube.spotube.resources.iconsax.IconsaxInformation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -60,36 +60,36 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.hazeEffect -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle -import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross -import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh -import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow -import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.downloads.DownloadProgressIcon +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross +import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow +import org.spotube.spotube.resources.iconsax.SwapHorizontal2 import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt index 3141f1dd..b2b8fd0a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt index d2a2714b..484fe48b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.ExperimentalFoundationApi @@ -54,20 +54,20 @@ import com.composeunstyled.UnstyledBottomSheet import com.composeunstyled.rememberBottomSheetState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.NavigationState -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost -import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost -import dev.krtirtho.spotube.modules.lyrics.LyricsScreen -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackSheet -import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContent -import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel -import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.ConnectionRequestDialogHost +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.modules.devices.PlayDestinationPickerHost +import org.spotube.spotube.modules.lyrics.LyricsScreen +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContent +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackSheet +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContent +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContentViewModel +import org.spotube.spotube.modules.shell.player_queue.QueueSheet import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt index e5e22813..482acbbf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import com.composeunstyled.currentWindowWidthBreakpoint diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt index e613d25b..a38af3bd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -40,20 +40,20 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.composeunstyled.Text -import dev.krtirtho.spotube.NavigationItem -import dev.krtirtho.spotube.core.navigation.NavigationState -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.LibraryTab -import dev.krtirtho.spotube.modules.support.LocalSupportPrompt -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken -import dev.krtirtho.spotube.sidebarTabs +import org.spotube.spotube.NavigationItem +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.spotube.spotube.resources.iconsax.IconsaxSidebarLeftBroken +import org.spotube.spotube.resources.iconsax.IconsaxSidebarRightBroken +import org.spotube.spotube.sidebarTabs import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt index 588c00fa..56c1f042 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -30,7 +30,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withLink import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme /** * Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id] diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt index 34397563..e20ce752 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import org.spotube.millennium.composables.MillenniumIcon @@ -23,19 +23,19 @@ import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.shape.CircleShape -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.saved_tracks.SavedState -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.saved_tracks.SavedState +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.saved_tracks.rememberIsSavedTracks +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt index d8098f6d..96cd9ca0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import org.spotube.millennium.composables.MillenniumDialogHeader @@ -41,7 +41,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -53,12 +53,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.core.server.CacheEntry +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.core.server.CacheEntry import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.library_tab_artists diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt index 012caee7..d010d1b2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -15,24 +15,24 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.server.CacheEntry -import dev.krtirtho.spotube.core.server.CacheManager -import dev.krtirtho.spotube.core.server.StreamingUrlRepository -import dev.krtirtho.spotube.core.server.TrackSourceRepository -import dev.krtirtho.spotube.core.server.normalizeManifestUrl -import dev.krtirtho.spotube.core.server.selectPreferredAudioStream -import dev.krtirtho.spotube.modules.plugin.AudioPluginSource -import dev.krtirtho.spotube.modules.settings.SettingsRepository +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.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.CacheEntry +import org.spotube.spotube.core.server.CacheManager +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.core.server.normalizeManifestUrl +import org.spotube.spotube.core.server.selectPreferredAudioStream +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerTimeFormat.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerTimeFormat.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt index a07f430c..ea7736ad 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerTimeFormat.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import kotlin.time.Duration diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt index d1303959..8e97ec04 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.alternative_track +package org.spotube.spotube.modules.shell.alternative_track import org.spotube.millennium.composables.MillenniumSpokeSpinner @@ -37,7 +37,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable @@ -51,9 +51,9 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle import org.koin.compose.viewmodel.koinViewModel import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt index 5ccd3101..891d7baf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.alternative_track +package org.spotube.spotube.modules.shell.alternative_track import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.server.AlternativeTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.AlternativeTracksRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt index 6162b809..97db75d2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.alternative_track +package org.spotube.spotube.modules.shell.alternative_track @@ -25,8 +25,8 @@ import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.modules.shell.player_queue.BottomSheetContent -import dev.krtirtho.spotube.modules.shell.player_queue.EndDrawerSheet +import org.spotube.spotube.modules.shell.player_queue.BottomSheetContent +import org.spotube.spotube.modules.shell.player_queue.EndDrawerSheet private val SlidingSheetBreakpoint = 840.dp diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 9c4c7685..648d1526 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.player_queue +package org.spotube.spotube.modules.shell.player_queue import org.spotube.millennium.composables.MillenniumDropdownMenuItem @@ -55,16 +55,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.composeunstyled.Text -import dev.krtirtho.spotube.core.jam.JamParticipant -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxDragHandle +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.spotube.millennium.composables.MillenniumDropdownMenu diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index f13e6b78..1738cdf8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.player_queue +package org.spotube.spotube.modules.shell.player_queue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamParticipant -import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt index 5d566a64..4f9d09c1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.player_queue +package org.spotube.spotube.modules.shell.player_queue import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt index 51486546..0781aae2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.support +package org.spotube.spotube.modules.support import org.spotube.millennium.composables.MillenniumDialogHeader import org.spotube.millennium.composables.MillenniumDialogTitle @@ -35,12 +35,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import dev.krtirtho.spotube.openUrlInBrowser -import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee -import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective -import dev.krtirtho.spotube.resources.iconsax.BrandPatreon -import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.openUrlInBrowser +import org.spotube.spotube.resources.iconsax.BrandBuyMeACoffee +import org.spotube.spotube.resources.iconsax.BrandOpencollective +import org.spotube.spotube.resources.iconsax.BrandPatreon +import org.spotube.spotube.resources.iconsax.Iconsax import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.composables.MillenniumButton diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt index d894ef08..535929f8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.update +package org.spotube.spotube.modules.update import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt index 8fff895e..234a0338 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.update +package org.spotube.spotube.modules.update import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.AppBuildInfo -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.AppBuildInfo +import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO @@ -47,7 +47,7 @@ data class AvailableAppUpdate( /** * Checks GitHub for a newer app release once on startup and exposes it for the UI to - * surface via [dev.krtirtho.spotube.modules.update.AppUpdateDialog]. This is an + * surface via [org.spotube.spotube.modules.update.AppUpdateDialog]. This is an * app-scoped singleton (not a ViewModel) since the check runs once for the whole * process, independent of any screen's lifecycle. */ diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt index 8b303d20..dc9d3388 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.update +package org.spotube.spotube.modules.update import org.spotube.millennium.composables.MillenniumAlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt index 187dd94d..0b941cde 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.update +package org.spotube.spotube.modules.update import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo -import dev.krtirtho.spotube.core.zipline.PluginService -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.plugin.PluginManagerStates -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginManagerStates +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -45,7 +45,7 @@ data class AvailablePluginUpdate( * Checks the currently active plugins for updates through their own [PluginService.use] * -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and * exposes at most one pending update at a time for the UI to surface via - * [dev.krtirtho.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped + * [org.spotube.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped * singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager] * independent of any screen's lifecycle. */ diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/webview/WebViewScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/webview/WebViewScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt index a3a80650..45e3e982 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/webview/WebViewScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.webview +package org.spotube.spotube.modules.webview import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.core.di.rememberLogger -import dev.krtirtho.spotube.core.webview.PlatformWebViewScreen -import dev.krtirtho.spotube.core.webview.WebViewController +import org.spotube.spotube.core.di.rememberLogger +import org.spotube.spotube.core.webview.PlatformWebViewScreen +import org.spotube.spotube.core.webview.WebViewController internal object WebViewScreen @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt index b40a78da..938035f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.welcome +package org.spotube.spotube.modules.welcome import org.jetbrains.compose.resources.StringResource import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt index 34d7be18..f42c1e66 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.welcome +package org.spotube.spotube.modules.welcome -import dev.krtirtho.spotube.modules.support.DonationOption +import org.spotube.spotube.modules.support.DonationOption import org.jetbrains.compose.resources.StringResource data class WelcomeUiState( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt index 3e19bf53..14014419 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.welcome +package org.spotube.spotube.modules.welcome import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import dev.krtirtho.spotube.core.DebugConfig -import dev.krtirtho.spotube.core.db.Database +import org.spotube.spotube.core.DebugConfig +import org.spotube.spotube.core.db.Database import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -36,7 +36,7 @@ import kotlinx.coroutines.launch /** * Owns whether the Welcome/Onboarding flow should be the app's start destination. * - * This lives in its own preference rather than [dev.krtirtho.spotube.modules.settings.UserSettings] + * This lives in its own preference rather than [org.spotube.spotube.modules.settings.UserSettings] * so the very first launch can be distinguished from "settings not read yet". It is a Koin * singleton so the app-level start route and the routed [WelcomeScreen] observe the same state. */ diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt index 0bb19540..d87384cc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.welcome +package org.spotube.spotube.modules.welcome import org.spotube.millennium.composables.MillenniumButton @@ -37,7 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -46,21 +46,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.modules.welcome.pages.BrandingPage -import dev.krtirtho.spotube.modules.welcome.pages.DonationPage -import dev.krtirtho.spotube.modules.welcome.pages.HowItWorksPage -import dev.krtirtho.spotube.modules.welcome.pages.PluginsPage -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.welcome.pages.BrandingPage +import org.spotube.spotube.modules.welcome.pages.DonationPage +import org.spotube.spotube.modules.welcome.pages.HowItWorksPage +import org.spotube.spotube.modules.welcome.pages.PluginsPage +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxNext import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.welcome_skip /** * First-run Welcome/Onboarding screen. It is a route - * ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell. + * ([org.spotube.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell. * * The steps are exposed through a Millennium tab group rather than ad-hoc dots or a footer * walk-through, so the screen reads as one deliberate panel on every window size. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt index 02120e8f..0320a529 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.welcome +package org.spotube.spotube.modules.welcome import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.modules.support.donationOptions +import org.spotube.spotube.modules.support.donationOptions import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -67,7 +67,7 @@ class WelcomeViewModel( fun openDonation(url: String) { // Only destinations owned by our static funding list can be opened here. if (donationOptions.none { it.url == url }) return - runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) } + runCatching { org.spotube.spotube.openUrlInBrowser(url) } } fun completeOnboarding() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt index 5b1d99f4..ae732f68 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.welcome +package org.spotube.spotube.modules.welcome import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -27,7 +27,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.TextUnit import com.composeunstyled.Text -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.theme.MillenniumTheme diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt index 42696f1a..9df108f0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt @@ -1,10 +1,10 @@ -package dev.krtirtho.spotube.modules.welcome.pages +package org.spotube.spotube.modules.welcome.pages import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -13,7 +13,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import dev.krtirtho.spotube.modules.welcome.WelcomeWordmark +import org.spotube.spotube.modules.welcome.WelcomeWordmark import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt index 30f516f7..6217e7b6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.modules.welcome.pages +package org.spotube.spotube.modules.welcome.pages import org.spotube.millennium.composables.MillenniumButton @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,7 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.modules.support.DonationOption +import org.spotube.spotube.modules.support.DonationOption import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt index f9453f16..e8764fa5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.modules.welcome.pages +package org.spotube.spotube.modules.welcome.pages import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumListTile @@ -10,18 +10,18 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt similarity index 66% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt index ab3e7477..3bbcb709 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt @@ -1,7 +1,7 @@ -package dev.krtirtho.spotube.modules.welcome.pages +package org.spotube.spotube.modules.welcome.pages import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.modules.plugin.PluginScreen +import org.spotube.spotube.modules.plugin.PluginScreen /** The same installation, discovery, permissions and sign-in flow as Settings, in-place. */ @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/ArrowLeft3.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/ArrowLeft3.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt index d51ba7ce..573512aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/ArrowLeft3.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt index 67779257..4b70c390 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt index cd5ba60f..8f7a9291 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt index 7029e29b..85c5b7a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt index c6273c77..969d05df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CustomServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CustomServer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt index 362d0dde..cbcaa164 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CustomServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentDismiss.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentDismiss.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt index 7ee6a290..58738d9f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentDismiss.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMaximize.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMaximize.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt index ceffbbad..04a5cfd9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMaximize.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMinus.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMinus.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt index 15b907fc..e4fc342d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMinus.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentSquareMultiple.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentSquareMultiple.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt index 7ca44d3e..9693d2cf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentSquareMultiple.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt new file mode 100644 index 00000000..9ad18022 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt @@ -0,0 +1,3 @@ +package org.spotube.spotube.resources.iconsax + +object Iconsax diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax3DotsMore.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax3DotsMore.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt index 62160400..cf4ec346 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax3DotsMore.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt index 520ebcc4..e7477dbe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAddSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAddSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt index a433ce28..82fd4fce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAddSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowDown4.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowDown4.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt index 2d56673e..f022bea8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowDown4.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowSquareUp.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowSquareUp.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt index 59516b40..5a6df3f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowSquareUp.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBox.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBox.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt index 9c71eb43..7b29531e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBox.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBoxAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBoxAdd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt index 06b11520..ddf2a7c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBoxAdd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt index 2ca12047..ae61ef25 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt index 1dd8454a..a7964c0e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt index 08d47f5b..8085b005 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCloseSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCloseSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt index e27c9e18..9fed5ef1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCloseSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorSwatch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorSwatch.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt index afd465d0..6884928c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorSwatch.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorsSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorsSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt index da5cce79..f29df961 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorsSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDirectboxReceive.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDirectboxReceive.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt index e4a763f7..e1b7caa6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDirectboxReceive.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentDownload.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentDownload.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt index 9c1ae5c2..aa30f2f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentDownload.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentText.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentText.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt index f4889441..fae03e12 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentText.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDragHandle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDragHandle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt index d5d44e41..fccc551e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDragHandle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt index 51227cce..9bfd13c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxExportArrowBulk.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxExportArrowBulk.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt index 5ab7e7b4..6871aae6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxExportArrowBulk.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEye.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEye.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt index f5304d04..49d58458 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEye.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFilterSearch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFilterSearch.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt index 427444a3..8176e8e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFilterSearch.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolder.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt index d817c099..6b665f45 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolder.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderAdd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt index f51ad4bc..07f8b64c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderAdd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderOpen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderOpen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt index 0a21adbf..2c01af64 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderOpen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt index ec73a525..832ba1db 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFormatCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFormatCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt index 50211bc4..b9070c2c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFormatCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxGlobe.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxGlobe.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt index b59f1e3b..2e05c51d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxGlobe.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt index 4d07781b..440b220b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt index 6a5877ff..943657b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHome.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHome.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt index 27f58c50..a802d9ba 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHome.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHomeBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHomeBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt index 8081f558..1cf29a51 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHomeBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt index 198e667e..4c6be580 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt index 7c31a0cf..4255e24e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxInformation.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxInformation.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt index b8b552b6..cbbc16e0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxInformation.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLanguageSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLanguageSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt index 7204461d..a99cd979 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLanguageSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLink.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLink.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt index a91f5e06..fa46bdc6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLink.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLocation.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLocation.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt index 545e4726..cf01a743 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLocation.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMagic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMagic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt index 14595f6f..a0d30b5e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMagic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMinusSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMinusSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt index 22a9de81..88a8f975 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMinusSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt index df99a0eb..b0459e66 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirroringScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirroringScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt index d6dc0d2e..4cfe7baf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirroringScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMoon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMoon.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt index a9dd219e..5c47d773 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMoon.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt index 703de396..b404c2bc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt index 2d157fcd..14bd6927 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicDashboard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicDashboard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt index 8591e793..c38c9ece 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicDashboard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicFilter.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicFilter.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt index 1192dffa..91bb39aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicFilter.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibrary.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibrary.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt index 37cb9576..2584e03b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibrary.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt index 75726088..843e577c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlay.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt index 5a28f4b5..423725a6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlaylist.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlaylist.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt index d8f043e1..59920a99 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlaylist.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt index d84c6d19..e82979e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt index 83baefb4..e5629855 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxNext.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxNext.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt index 7333b73a..c7fb610b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxNext.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPause.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPause.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt index 096a3b71..f7735bad 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPause.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPauseCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPauseCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt index 81f70bae..9eb8430d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPauseCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlay.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt index 66ca07d9..36cdabd9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt index b9c81564..ae5334bd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt index f2b95adb..8c6c54ba 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPrevious.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPrevious.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt index b5197971..34545a14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPrevious.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshArrow2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshArrow2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt index 2be1ec7d..8ae9eb93 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshArrow2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshRight.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshRight.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt index 0a6e186b..ed56e82d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshRight.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatArrow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatArrow.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt index d68aba5d..919d5887 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatArrow.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatMusic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt index 2f6fd2a1..9ac0a975 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatMusic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateMusic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt index f5d3442f..f7088220 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateMusic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateOne.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateOne.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt index d4cfaf28..77151a13 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateOne.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearch.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt index 2340e13c..93489b51 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearch.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearchBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearchBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt index 73b0554f..31002b0d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearchBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt index 8fab354c..22554bfe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt index 5f305537..1580a167 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSettingTwotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSettingTwotone.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt index 46cc447b..84043b40 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSettingTwotone.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt index 769f22f8..e7592b34 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShuffle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShuffle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt index d369fe57..497be379 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShuffle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt index 0d9381f0..af403ddf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt index 35f6e6cd..ecc9486d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSort.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSort.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt index 46b0afd1..c2c01626 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSort.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt index 6bb517c5..c38f5c07 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSoundTwotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSoundTwotone.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt index 4021e59a..e7961ae7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSoundTwotone.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt index 6d81de56..c60404fb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxStopCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxStopCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt index d095cbbc..4c6bc6f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxStopCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTag.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTag.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt index f789adcb..0764a5c2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTag.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTextalignLeft.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTextalignLeft.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt index cb9d5daa..4608269f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTextalignLeft.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTrash.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTrash.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt index eb81b633..3b6247ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTrash.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt index b69a5663..0880483b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVerticalSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVerticalSetting.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt index 60258c82..1cfdece4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVerticalSetting.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeCross.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeCross.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt index 4ea83846..55c050ce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeCross.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeHigh.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeHigh.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt index 2f4498f3..1001490e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeHigh.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeLow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeLow.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt index 484258aa..3889166f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeLow.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxWifiSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxWifiSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt index 49ab9927..83fe5866 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxWifiSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/InconsaxClock.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/InconsaxClock.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt index b7ff81fd..87c8078e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/InconsaxClock.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/LineDiscord.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/LineDiscord.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt index 37d96b17..6d0efd61 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/LineDiscord.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt index 7e74f60c..4bbf52df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Play.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Play.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt index d52dc6be..c2bd063a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Play.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/SwapHorizontal2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/SwapHorizontal2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt index f73604fd..64f351af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/SwapHorizontal2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/User.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/User.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt index 6f9368ca..f5540c5c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/User.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/templates/AppBuildInfo.kt.template b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template index b0435644..b1dee0db 100644 --- a/composeApp/src/commonMain/templates/AppBuildInfo.kt.template +++ b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube internal object AppBuildInfo { const val VERSION = "${appVersion}" diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt similarity index 98% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt index 08c42b0f..2711d897 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt similarity index 96% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index 3bc272c8..5dde1c65 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -1,12 +1,12 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext @@ -446,7 +446,7 @@ class DeviceAudioPlayerQueueTest { fun `streaming track converts to media item`() = runTest { val queue = createQueue() fakeSettings.setSettings( - dev.krtirtho.spotube.modules.settings.UserSettings(playbackProxyServerPort = 14769) + org.spotube.spotube.modules.settings.UserSettings(playbackProxyServerPort = 14769) ) val entry = streamingEntry("t1") diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt similarity index 99% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt index ebfbc450..4f6fa948 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt similarity index 91% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt index 77cea29e..b9149c83 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer class FakeAudioPlayerQueueRepository : QueueStateRepository { private var persistedState: PersistedQueueState? = null diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt similarity index 76% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt index df4eae48..8793f64e 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt @@ -1,10 +1,10 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt similarity index 76% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt index 1b59eaaa..b3daab34 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt @@ -1,12 +1,12 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.spotube.core.zipline.PluginService -import dev.krtirtho.spotube.core.zipline.PluginServiceScope -import dev.krtirtho.spotube.modules.plugin.PluginProvider +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.PluginProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt similarity index 79% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt index cafdb7b1..c979107d 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt @@ -1,7 +1,7 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.UserSettings import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt similarity index 92% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt index c7857fe4..f4549508 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt @@ -1,20 +1,20 @@ -package dev.krtirtho.spotube.core.scrobble +package org.spotube.spotube.core.scrobble import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.FakeAudioPlayer -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.zipline.PluginService -import dev.krtirtho.spotube.core.zipline.PluginServiceScope -import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.FakeAudioPlayer +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.ScrobblePluginSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt similarity index 97% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index 3c70a4f0..b956d423 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -1,12 +1,12 @@ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +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.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.settings.UserSettings import co.touchlab.kermit.Logger import kotlinx.coroutines.test.runTest import org.koin.core.context.startKoin diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt similarity index 87% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt index a52298dc..d8f1af5f 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -1,17 +1,17 @@ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.zipline.PluginService -import dev.krtirtho.spotube.core.zipline.PluginServiceScope -import dev.krtirtho.spotube.modules.plugin.AudioPluginSource -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.UserSettingsSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.UserSettingsSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt similarity index 97% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt index d11fe33e..e950747a 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -1,9 +1,9 @@ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DESTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt similarity index 95% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DESTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt index 81472ed7..088f642b 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DESTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis.additionals +package org.spotube.spotube.core.zipline.host_apis.additionals -import dev.krtirtho.plugin_interfaces.host_apis.PaddingTypes -import dev.krtirtho.plugin_interfaces.host_apis.SymmetricModes +import org.spotube.plugin_interfaces.host_apis.PaddingTypes +import org.spotube.plugin_interfaces.host_apis.SymmetricModes import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertContentEquals diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt similarity index 96% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt index d7dca15e..1e72f984 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,7 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.window.ComposeUIViewController import com.yushosei.newpipe.extractor.NewPipe import com.yushosei.newpipe.util.DefaultDownloaderImpl -import dev.krtirtho.spotube.core.di.initKoin +import org.spotube.spotube.core.di.initKoin import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt similarity index 97% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt index aa38bb5f..7f8ca2c3 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import platform.Foundation.NSURL import platform.UIKit.UIApplication diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt similarity index 99% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 016993eb..3c99747b 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -17,7 +17,7 @@ @file:OptIn(ExperimentalForeignApi::class) -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.cinterop.CValue import kotlinx.cinterop.ExperimentalForeignApi diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt similarity index 66% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt index c1b3efff..412db38c 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.share.IosShareService -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.modules.library.local_tracks.media.IosLocalMediaDiscoveryService -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.IosShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.modules.library.local_tracks.media.IosLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService import org.koin.core.module.dsl.singleOf import org.koin.dsl.module diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt similarity index 94% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt index 4b02635f..17a4a805 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.discovery +package org.spotube.spotube.core.discovery import androidx.compose.runtime.Composable diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt similarity index 97% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt index aaf1c7d2..4ac27df9 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.i18n +package org.spotube.spotube.core.i18n import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidedValue diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt similarity index 96% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index 26a76fab..f70b3a39 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe import com.yushosei.newpipe.extractor.ServiceList.YouTube import com.yushosei.newpipe.extractor.stream.AudioStream @@ -75,7 +75,7 @@ actual class NewPipeService { audioStreams = extractor.audioStreams() .filter { stream -> stream.isUrl() && stream.format != null } .map { stream -> - dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( + org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( url = stream.content, codec = stream.codec ?: "unknown", bitrate = stream.bitrate, diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt similarity index 98% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt index 516a4ccc..662d3467 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSCachesDirectory diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/share/IosShareService.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt similarity index 93% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/share/IosShareService.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt index 8a22a3c2..6499c550 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/share/IosShareService.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share -import dev.krtirtho.spotube.core.share.ShareService +import org.spotube.spotube.core.share.ShareService import platform.Foundation.NSURL import platform.UIKit.UIActivityViewController import platform.UIKit.UIApplication diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt similarity index 95% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt index 807346fe..9b1ed717 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import dev.nucleusframework.webview.web.WebViewState import kotlinx.cinterop.ExperimentalForeignApi diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt similarity index 96% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt index 7c0a21e4..8249ae1e 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt similarity index 94% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt index dbc48426..76178dde 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media class IosLocalMediaDiscoveryService : LocalMediaDiscoveryService { override suspend fun discoverFolders(roots: List): List { diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt similarity index 93% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt index 9de9e195..db540813 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.compose.runtime.Composable diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt similarity index 98% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt index b816c283..ad2c1b57 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import java.awt.Desktop import java.net.URI diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt similarity index 99% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt index 9ddcd48b..37cb7a42 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.generated.VLCBundleLoaderGenerated +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.generated.VLCBundleLoaderGenerated import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt similarity index 67% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt index 8c8c1936..d668c914 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt @@ -15,17 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.mediacontrol.SystemMediaControlService -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.share.JvmShareService -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.mediacontrol.SystemMediaControlService +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.JvmShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.withOptions diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt similarity index 94% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt index 68522b82..62b8f80c 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.discovery +package org.spotube.spotube.core.discovery import androidx.compose.runtime.Composable diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt similarity index 97% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt index d4d728ef..9740ce31 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/i18n/AppLocale.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.i18n +package org.spotube.spotube.core.i18n import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidedValue diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt similarity index 95% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt index a227be0a..d5e2a2dc 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.mediacontrol +package org.spotube.spotube.core.mediacontrol -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.MediaItem -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.di.injectLogger import dev.nucleusframework.media.control.MediaControlEvent import dev.nucleusframework.media.control.MediaControlService import dev.nucleusframework.media.control.MediaMetadata diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt similarity index 97% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt index 652081af..54d70521 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths import net.harawata.appdirs.AppDirsFactory diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/share/JvmShareService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt similarity index 91% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/share/JvmShareService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt index a56407e7..096e23d0 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/share/JvmShareService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share -import dev.krtirtho.spotube.core.share.ShareService +import org.spotube.spotube.core.share.ShareService import java.awt.Toolkit import java.awt.datatransfer.StringSelection diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt similarity index 96% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt index bbc55527..50f55bf5 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.systemtray +package org.spotube.spotube.core.systemtray import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxMusic import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.composenativetray.tray.api.Tray import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt similarity index 91% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt index c4baf2a7..9c5615ea 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.systemtray +package org.spotube.spotube.core.systemtray -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.MediaItem -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt similarity index 92% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt index a6ed3e85..7e007e12 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumIcon @@ -43,13 +43,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement -import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.krtirtho.spotube.resources.iconsax.FluentDismiss -import dev.krtirtho.spotube.resources.iconsax.FluentMaximize -import dev.krtirtho.spotube.resources.iconsax.FluentMinus -import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple -import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.resources.iconsax.FluentDismiss +import org.spotube.spotube.resources.iconsax.FluentMaximize +import org.spotube.spotube.resources.iconsax.FluentMinus +import org.spotube.spotube.resources.iconsax.FluentSquareMultiple +import org.spotube.spotube.resources.iconsax.Iconsax import dev.nucleusframework.window.WindowControls import dev.nucleusframework.window.WindowControlsRenderer import dev.nucleusframework.window.WindowDoubleClickAction diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt similarity index 95% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt index ab238909..5f5eb7ed 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.window.ApplicationScope diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt similarity index 96% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt index f95f7cf1..b9912cbc 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.window.WindowState diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt similarity index 95% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt index 25f984c1..4adf2c8d 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.LocalScrollbarStyle import androidx.compose.foundation.ScrollbarStyle @@ -30,7 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.AppTheme @Composable actual fun VerticalScrollbar( diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt similarity index 92% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt index 818f3ab5..08f1f802 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.paths.Paths import dev.nucleusframework.webview.web.WebViewState import okio.FileSystem import okio.Path.Companion.toPath diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt similarity index 96% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt index b05bcc5e..3591c937 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.Executors diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt similarity index 83% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index df97c006..32cb1500 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState @@ -28,18 +28,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState import com.sun.jna.Library import com.sun.jna.Native -import dev.krtirtho.spotube.core.di.initKoin -import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler -import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.systemtray.SystemTray -import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope -import dev.krtirtho.spotube.core.ui.component.LocalWindowScope -import dev.krtirtho.spotube.core.ui.component.LocalWindowState -import dev.krtirtho.spotube.core.i18n.AppLocaleProvider -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.krtirtho.spotube.modules.settings.SupportedLanguages +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.deeplink.ExternalUriHandler +import org.spotube.spotube.core.newpipe.NewPipeDownloader +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.systemtray.SystemTray +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.core.ui.component.LocalApplicationScope +import org.spotube.spotube.core.ui.component.LocalWindowScope +import org.spotube.spotube.core.ui.component.LocalWindowState +import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.SupportedLanguages import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication @@ -62,7 +62,7 @@ private object KoinServicesProvider : KoinComponent { @OptIn(ExperimentalComposeUiApi::class) fun main() { - FileKit.init(appId = "dev.krtirtho.spotube") + FileKit.init(appId = "org.spotube.spotube") initKoin() NewPipeDownloader.init(KoinPathsProvider.paths) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt similarity index 99% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt index 405ef268..d7dd7ab0 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt similarity index 93% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt index 9de9e195..db540813 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.compose.runtime.Composable diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt similarity index 99% rename from composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt rename to composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt index 28a9e548..f78eb438 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt @@ -1,7 +1,7 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/i18n/AppLocaleProviderTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt similarity index 97% rename from composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/i18n/AppLocaleProviderTest.kt rename to composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt index 91dac67b..9e30c275 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/i18n/AppLocaleProviderTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.i18n +package org.spotube.spotube.core.i18n import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue @@ -26,7 +26,7 @@ import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.use -import dev.krtirtho.spotube.modules.settings.SupportedLanguages +import org.spotube.spotube.modules.settings.SupportedLanguages import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.pluralStringResource diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt similarity index 91% rename from composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt rename to composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt index 44efa651..7a67d3bb 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.jam +package org.spotube.spotube.modules.jam import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -22,19 +22,19 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.unit.dp import androidx.compose.ui.use -import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES -import dev.krtirtho.spotube.core.navigation.rememberNavigationState -import dev.krtirtho.spotube.modules.settings.Theme -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.shell.AppShellViewModel -import dev.krtirtho.spotube.modules.shell.FloatingPlayerContent -import dev.krtirtho.spotube.modules.shell.FloatingPlayerBottomBleed -import dev.krtirtho.spotube.modules.shell.PlayerUiState -import dev.krtirtho.spotube.modules.shell.AppBottombar -import dev.krtirtho.spotube.modules.shell.CompactPlayerOverlay +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.shell.AppShellViewModel +import org.spotube.spotube.modules.shell.FloatingPlayerContent +import org.spotube.spotube.modules.shell.FloatingPlayerBottomBleed +import org.spotube.spotube.modules.shell.PlayerUiState +import org.spotube.spotube.modules.shell.AppBottombar +import org.spotube.spotube.modules.shell.CompactPlayerOverlay import org.spotube.millennium.composables.MillenniumTopAppBar import org.jetbrains.compose.resources.stringResource import com.composeunstyled.Text diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt similarity index 97% rename from composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt rename to composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt index 872ae802..4f397642 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width @@ -17,9 +17,9 @@ import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.unit.dp import androidx.compose.ui.use import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme -import dev.krtirtho.spotube.modules.settings.Theme -import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt similarity index 92% rename from composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt rename to composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt index dec49ce7..f8fdef07 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -25,26 +25,26 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.unit.dp import androidx.compose.ui.use -import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile -import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme -import dev.krtirtho.spotube.modules.downloads.DownloadItem -import dev.krtirtho.spotube.modules.downloads.DownloadRowAction -import dev.krtirtho.spotube.modules.downloads.DownloadStatus -import dev.krtirtho.spotube.modules.downloads.DownloadsContent -import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarTitle -import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarSummary -import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarActions -import dev.krtirtho.spotube.modules.downloads.downloadsUiState -import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksState -import dev.krtirtho.spotube.modules.library.local_tracks.LocalTracksContent -import dev.krtirtho.spotube.modules.library.local_tracks.LocalTracksBreadcrumb -import dev.krtirtho.spotube.modules.library.local_tracks.localTracksUiState -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack -import dev.krtirtho.spotube.modules.settings.Theme -import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.downloads.DownloadItem +import org.spotube.spotube.modules.downloads.DownloadRowAction +import org.spotube.spotube.modules.downloads.DownloadStatus +import org.spotube.spotube.modules.downloads.DownloadsContent +import org.spotube.spotube.modules.downloads.DownloadsToolbarTitle +import org.spotube.spotube.modules.downloads.DownloadsToolbarSummary +import org.spotube.spotube.modules.downloads.DownloadsToolbarActions +import org.spotube.spotube.modules.downloads.downloadsUiState +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksState +import org.spotube.spotube.modules.library.local_tracks.LocalTracksContent +import org.spotube.spotube.modules.library.local_tracks.LocalTracksBreadcrumb +import org.spotube.spotube.modules.library.local_tracks.localTracksUiState +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings import java.io.File import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.composables.MillenniumTopAppBar diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt similarity index 97% rename from composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt rename to composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt index d577fd09..1d34b589 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt @@ -1,7 +1,7 @@ /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -15,8 +15,8 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.use import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme -import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersEditorContent import java.io.File import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt similarity index 97% rename from composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt rename to composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt index b60bd16c..9a812193 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt similarity index 95% rename from composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.mobile.kt rename to composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt index 080a02ea..174696b7 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 3cef6a96..5f1e25ca 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,7 +1,7 @@ TEAM_ID= PRODUCT_NAME=Spotube -PRODUCT_BUNDLE_IDENTIFIER=dev.krtirtho.spotube.Spotube$(TEAM_ID) +PRODUCT_BUNDLE_IDENTIFIER=org.spotube.spotube.Spotube$(TEAM_ID) CURRENT_PROJECT_VERSION=1 MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index a07e2e68..5663e50d 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -8,7 +8,7 @@ CFBundleURLName - dev.krtirtho.spotube + org.spotube.spotube CFBundleURLSchemes spotube diff --git a/js_plugin_example/build.gradle.kts b/js_plugin_example/build.gradle.kts index ac43b30d..6b88fb8a 100644 --- a/js_plugin_example/build.gradle.kts +++ b/js_plugin_example/build.gradle.kts @@ -47,7 +47,7 @@ kotlin { } zipline { - mainFunction.set("dev.krtirtho.js_plugin_example.main") + mainFunction.set("org.spotube.js_plugin_example.main") } plugins.withType { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/js.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/js.kt deleted file mode 100644 index 0b7235ee..00000000 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/js.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 . - */ - -package dev.krtirtho.js_plugin_example - -import app.cash.zipline.Zipline -import dev.krtirtho.js_plugin_example.plugin_apis.audio.RealAudioAPI -import dev.krtirtho.js_plugin_example.plugin_apis.core.RealCoreAPI -import dev.krtirtho.js_plugin_example.plugin_apis.lyrics.RealLyricsAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataAlbumAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataArtistAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataBrowseAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataPlaylistAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataSearchAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataTrackAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataUserAPI -import dev.krtirtho.js_plugin_example.plugin_apis.scrobble.RealScrobbleAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME -import kotlin.js.Date - -private val zipline by lazy { Zipline.get() } - -@OptIn(ExperimentalJsExport::class) -@JsExport -fun main() { - zipline.take(HttpClientAPI_SERVICE_NAME) - zipline.take(WebViewAPI_SERVICE_NAME) - zipline.take(PersistedStorageAPI_SERVICE_NAME) - console.log("Epoch Time: ${Date.now()}") - - zipline.bind(CoreAPI_SERVICE_NAME, RealCoreAPI()) - zipline.bind(AudioAPI_SERVICE_NAME, RealAudioAPI()) - zipline.bind(LyricsAPI_SERVICE_NAME, RealLyricsAPI()) - zipline.bind(ScrobbleAPI_SERVICE_NAME, RealScrobbleAPI()) - - zipline.bind(MetadataAlbumAPI_SERVICE_NAME, RealMetadataAlbumAPI()) - zipline.bind(MetadataArtistAPI_SERVICE_NAME, RealMetadataArtistAPI()) - zipline.bind(MetadataBrowseAPI_SERVICE_NAME, RealMetadataBrowseAPI()) - zipline.bind(MetadataPlaylistAPI_SERVICE_NAME, RealMetadataPlaylistAPI()) - zipline.bind(MetadataSearchAPI_SERVICE_NAME, RealMetadataSearchAPI()) - zipline.bind(MetadataTrackAPI_SERVICE_NAME, RealMetadataTrackAPI()) - zipline.bind(MetadataUserAPI_SERVICE_NAME, RealMetadataUserAPI()) -} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt new file mode 100644 index 00000000..4893cd32 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt @@ -0,0 +1,84 @@ +/* + * 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 . + */ + +package org.spotube.js_plugin_example + +import app.cash.zipline.Zipline +import org.spotube.js_plugin_example.plugin_apis.audio.RealAudioAPI +import org.spotube.js_plugin_example.plugin_apis.core.RealCoreAPI +import org.spotube.js_plugin_example.plugin_apis.lyrics.RealLyricsAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataAlbumAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataArtistAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataBrowseAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataPlaylistAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataSearchAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataTrackAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataUserAPI +import org.spotube.js_plugin_example.plugin_apis.scrobble.RealScrobbleAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import kotlin.js.Date + +private val zipline by lazy { Zipline.get() } + +@OptIn(ExperimentalJsExport::class) +@JsExport +fun main() { + zipline.take(HttpClientAPI_SERVICE_NAME) + zipline.take(WebViewAPI_SERVICE_NAME) + zipline.take(PersistedStorageAPI_SERVICE_NAME) + console.log("Epoch Time: ${Date.now()}") + + zipline.bind(CoreAPI_SERVICE_NAME, RealCoreAPI()) + zipline.bind(AudioAPI_SERVICE_NAME, RealAudioAPI()) + zipline.bind(LyricsAPI_SERVICE_NAME, RealLyricsAPI()) + zipline.bind(ScrobbleAPI_SERVICE_NAME, RealScrobbleAPI()) + + zipline.bind(MetadataAlbumAPI_SERVICE_NAME, RealMetadataAlbumAPI()) + zipline.bind(MetadataArtistAPI_SERVICE_NAME, RealMetadataArtistAPI()) + zipline.bind(MetadataBrowseAPI_SERVICE_NAME, RealMetadataBrowseAPI()) + zipline.bind(MetadataPlaylistAPI_SERVICE_NAME, RealMetadataPlaylistAPI()) + zipline.bind(MetadataSearchAPI_SERVICE_NAME, RealMetadataSearchAPI()) + zipline.bind(MetadataTrackAPI_SERVICE_NAME, RealMetadataTrackAPI()) + zipline.bind(MetadataUserAPI_SERVICE_NAME, RealMetadataUserAPI()) +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt similarity index 89% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt index 85dd4f1a..6f861550 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.audio +package org.spotube.js_plugin_example.plugin_apis.audio -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +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.metadata.track.MetadataTrack val urls = listOf( "https://cdn.pixabay.com/audio/2024/02/28/audio_d1a9995fc3.mp3", diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/core/RealCoreAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt similarity index 88% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/core/RealCoreAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt index 7848733c..9a25de0a 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/core/RealCoreAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.core +package org.spotube.js_plugin_example.plugin_apis.core -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt similarity index 87% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt index 6756a84e..8d776b8f 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.lyrics +package org.spotube.js_plugin_example.plugin_apis.lyrics -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricType -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.SyncLyricsLine +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricType +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.SyncLyricsLine class RealLyricsAPI : LyricsAPI { override val supportedLyricTypes: List = listOf(LyricType.STATIC, LyricType.SYNCED) diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt similarity index 95% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt index 89d2170e..4d858495 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt @@ -15,20 +15,20 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser private data class ArtistEntity( val id: String, diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt similarity index 80% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt index a0796eb6..7a6c9f25 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack class RealMetadataAlbumAPI : MetadataAlbumAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt similarity index 76% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt index ed05a111..9e40e0e7 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack class RealMetadataArtistAPI : MetadataArtistAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt similarity index 76% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt index b2872544..b2463688 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult class RealMetadataBrowseAPI : MetadataBrowseAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt similarity index 87% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt index d76dac0c..c45b307f 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack class RealMetadataPlaylistAPI : MetadataPlaylistAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt similarity index 83% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt index e015435b..ca8985ee 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType class RealMetadataSearchAPI : MetadataSearchAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt similarity index 81% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt index ce28cb01..3237601c 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI class RealMetadataTrackAPI : MetadataTrackAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt similarity index 80% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt index 61828f38..dda9a334 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI class RealMetadataUserAPI : MetadataUserAPI { override suspend fun getUser(id: String): MetadataUser? { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt similarity index 80% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt index 03aeec85..85b8d901 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.scrobble +package org.spotube.js_plugin_example.plugin_apis.scrobble -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack class RealScrobbleAPI: ScrobbleAPI { override suspend fun scrobble(track: ScrobbleTrack) { diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 74209eac..f6d76556 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -2,7 +2,7 @@ pkgbase = spotube-bin pkgdesc = Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! pkgver = 4.0.0 pkgrel = 1 -url = https://spotube.cc +url = https://spotube.org arch = x86_64 license = AGPL-3.0-or-later depends = mpv @@ -13,7 +13,7 @@ depends = libnotify depends = xdg-user-dirs depends = webkit2gtk-4.1 optdepends = yt-dlp-git -source = https://github.com/KRTirtho/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz +source = https://github.com/team-spotube/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz md5sums = 475b1ae9b08f27743a4d4749391ae3db pkgname = spotube-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 3fc7d677..da28fde8 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -5,7 +5,7 @@ pkgrel=%{{PKGREL}}% epoch= pkgdesc="Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" arch=(x86_64) -url="https://spotube.cc" +url="https://spotube.org" license=('AGPL-3.0-or-later') groups=() depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') @@ -19,7 +19,7 @@ backup=() options=() install= changelog= -source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") +source=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") noextract=() md5sums=(%{{LINUX_MD5}}%) validpgpkeys=() diff --git a/packaging/chocolatey/spotube.nuspec b/packaging/chocolatey/spotube.nuspec index 148e089e..ec0ec79a 100644 --- a/packaging/chocolatey/spotube.nuspec +++ b/packaging/chocolatey/spotube.nuspec @@ -6,23 +6,23 @@ enclosed in quotation marks, you should use an editor that supports UTF-8, not t spotube %{{SPOTUBE_VERSION}}% - https://github.com/KRTirtho/spotube/tree/master/choco-struct + https://github.com/team-spotube/spotube/tree/master/choco-struct Kingkor Roy Tirtho spotube (Install) Kingkor Roy Tirtho - https://spotube.cc + https://spotube.org - https://rawcdn.githack.com/KRTirtho/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/branding/spotube-logo.png + https://rawcdn.githack.com/team-spotube/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/branding/spotube-logo.png 2022 Spotube - https://github.com/KRTirtho/spotube/blob/master/LICENSE + https://github.com/team-spotube/spotube/blob/master/LICENSE true - https://github.com/KRTirtho/spotube - https://spotube.cc - https://github.com/KRTirtho/spotube/issues/new + https://github.com/team-spotube/spotube + https://spotube.org + https://github.com/team-spotube/spotube/issues/new spotube music audio youtube flutter 🎧 Open source music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! @@ -47,7 +47,7 @@ enclosed in quotation marks, you should use an editor that supports UTF-8, not t - Time synced lyrics - Downloadable tracks - https://github.com/KRTirtho/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% + https://github.com/team-spotube/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% diff --git a/packaging/chocolatey/tools/VERIFICATION.txt b/packaging/chocolatey/tools/VERIFICATION.txt index 8b4f01af..9ca4799f 100644 --- a/packaging/chocolatey/tools/VERIFICATION.txt +++ b/packaging/chocolatey/tools/VERIFICATION.txt @@ -5,10 +5,10 @@ in verifying that this package's contents are trustworthy. 1. The embedded files are also available as a release in GitHub. Please go to releases page -https://github.com/KRTirtho/spotube/releases +https://github.com/team-spotube/spotube/releases Download same version as this choco package (example for v%{{SPOTUBE_VERSION}}%) -https://github.com/KRTirtho/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% +https://github.com/team-spotube/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% 1. get hashes. Run: powershell -command Get-FileHash tools\Spotube-windows-x86_64-setup.exe diff --git a/plugin_interfaces/build.gradle.kts b/plugin_interfaces/build.gradle.kts index b079c3eb..4528ce16 100644 --- a/plugin_interfaces/build.gradle.kts +++ b/plugin_interfaces/build.gradle.kts @@ -23,7 +23,7 @@ plugins { alias(libs.plugins.zipline.gradle.plugin) } -group = "dev.krtirtho.spotube" +group = "org.spotube.spotube" version = "0.1.0" kotlin { @@ -32,7 +32,7 @@ kotlin { // which platforms this KMP module supports. // See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets androidLibrary { - namespace = "dev.krtirtho.spotube.plugin_interfaces" + namespace = "org.spotube.spotube.plugin_interfaces" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() @@ -105,7 +105,7 @@ mavenPublishing { name = "plugin_interfaces" description = "Plugin interfaces for Spotube" inceptionYear = "2026" - url = "https://github.com/KRTirtho/spotube" + url = "https://github.com/team-spotube/spotube" licenses { license { name = "AGPL-3.0-or-later" @@ -117,13 +117,13 @@ mavenPublishing { developer { id = "KRTirtho" name = "Kingkor Roy Tirtho" - url = "https://github.com/KRTirtho/" + url = "https://github.com/team-spotube/" } } scm { - url = "https://github.com/KRTirtho/spotube" - connection = "scm:git:git://github.com/KRTirtho/spotube.git" - developerConnection = "scm:git:ssh://git@github.com/KRTirtho/spotube.git" + url = "https://github.com/team-spotube/spotube" + connection = "scm:git:git://github.com/team-spotube/spotube.git" + developerConnection = "scm:git:ssh://git@github.com/team-spotube/spotube.git" } } } \ No newline at end of file diff --git a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.android.kt b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.android.kt similarity index 94% rename from plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.android.kt rename to plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.android.kt index 6eae50bd..4af9ff8b 100644 --- a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.android.kt +++ b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core actual fun runPluginInitialized(block: suspend () -> Unit) { } \ No newline at end of file diff --git a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt similarity index 93% rename from plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt rename to plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt index fad98790..c6b5fc53 100644 --- a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt +++ b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { } \ No newline at end of file diff --git a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.android.kt b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.android.kt similarity index 96% rename from plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.android.kt rename to plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.android.kt index b69ed577..32d679ce 100644 --- a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.android.kt +++ b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) { diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.kt similarity index 89% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.kt index 7401d175..8c9ac44b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt similarity index 89% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt index 6c2aa09d..c539ce3e 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt @@ -15,6 +15,6 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal expect fun polyfill() \ No newline at end of file diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.kt similarity index 94% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.kt index 98fd3dda..d6a4b74c 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") expect class Logger(tag: String) { diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HeadersBuilder.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HeadersBuilder.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HeadersBuilder.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HeadersBuilder.kt index 7d3188a1..921831e4 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HeadersBuilder.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HeadersBuilder.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor class HeadersBuilder { private val headers: MutableMap> = mutableMapOf() diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt index 7c7df669..9bc56c7b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor -import dev.krtirtho.plugin_interfaces.host_apis.HttpMethod +import org.spotube.plugin_interfaces.host_apis.HttpMethod import kotlinx.serialization.json.Json import kotlinx.serialization.serializer diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Interceptor.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Interceptor.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Interceptor.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Interceptor.kt index 57f8eed6..057d813d 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Interceptor.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Interceptor.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor interface Interceptor { suspend fun intercept(context: InterceptorContext): SpotrHttpResponse diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Serializers.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Serializers.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Serializers.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Serializers.kt index cb7bf06a..e0f5bbc7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Serializers.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Serializers.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClient.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClient.kt similarity index 97% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClient.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClient.kt index 8259f725..1f2d5887 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClient.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClient.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpMethod +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpMethod class SpotrClient( private val httpClientAPI: HttpClientAPI, diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClientConfig.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClientConfig.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClientConfig.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClientConfig.kt index 9c941d50..510bf2e5 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClientConfig.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClientConfig.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor class SpotrClientConfig( var defaultUrl: String = "", diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt similarity index 97% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt index 833d2b22..bae6ee3f 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor import kotlinx.serialization.serializer diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/URLBuilder.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/URLBuilder.kt similarity index 97% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/URLBuilder.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/URLBuilder.kt index bf9aa3b7..23469d47 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/URLBuilder.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/URLBuilder.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor class URLBuilder { var protocol: String = "https" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/CryptoAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/CryptoAPI.kt similarity index 99% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/CryptoAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/CryptoAPI.kt index ea7c0e54..9e03efc6 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/CryptoAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/CryptoAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/HttpClientAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/HttpClientAPI.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/HttpClientAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/HttpClientAPI.kt index 1a44e7fa..0f893fd7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/HttpClientAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/HttpClientAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/PersistedStorageAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/PersistedStorageAPI.kt similarity index 95% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/PersistedStorageAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/PersistedStorageAPI.kt index eea2df55..8c8cd5ed 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/PersistedStorageAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/PersistedStorageAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/SystemInformationAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/SystemInformationAPI.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/SystemInformationAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/SystemInformationAPI.kt index ae4a38a5..c577752c 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/SystemInformationAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/SystemInformationAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/WebViewAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/WebViewAPI.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/WebViewAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/WebViewAPI.kt index 2207ce3c..588156da 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/WebViewAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/WebViewAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.Flow diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioAPI.kt similarity index 88% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioAPI.kt index cb3a5565..bd62a3d9 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.audio +package org.spotube.plugin_interfaces.plugin_apis.audio import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val AudioAPI_SERVICE_NAME = "AudioAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioModels.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioModels.kt index 4f2d341e..7b26c922 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioModels.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.audio +package org.spotube.plugin_interfaces.plugin_apis.audio -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPI.kt similarity index 95% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPI.kt index a2fd25b2..015b3377 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.core +package org.spotube.plugin_interfaces.plugin_apis.core import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.StateFlow diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt similarity index 94% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt index 71161f45..6209aa5a 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.core +package org.spotube.plugin_interfaces.plugin_apis.core import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt similarity index 87% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt index 6dd2ce83..c23489e8 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.lyrics +package org.spotube.plugin_interfaces.plugin_apis.lyrics import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val LyricsAPI_SERVICE_NAME = "LyricsAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt similarity index 94% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt index 49f54189..28dd63ba 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.lyrics +package org.spotube.plugin_interfaces.plugin_apis.lyrics import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt similarity index 82% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt index fd573531..a46d574b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album +package org.spotube.plugin_interfaces.plugin_apis.metadata.album import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val MetadataAlbumAPI_SERVICE_NAME = "MetadataAlbumAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt similarity index 90% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt index d8e16737..876a28b8 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album +package org.spotube.plugin_interfaces.plugin_apis.metadata.album -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt similarity index 79% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt index df5b383f..1e07cc4b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist +package org.spotube.plugin_interfaces.plugin_apis.metadata.artist import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt similarity index 80% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt index 2e66061e..86b25928 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist +package org.spotube.plugin_interfaces.plugin_apis.metadata.artist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt similarity index 84% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt index 1110143e..83a68a6b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse +package org.spotube.plugin_interfaces.plugin_apis.metadata.browse import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt similarity index 78% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt index 940c15bc..4abb010b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse +package org.spotube.plugin_interfaces.plugin_apis.metadata.browse -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt similarity index 95% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt index 875e0886..de29829b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common +package org.spotube.plugin_interfaces.plugin_apis.metadata.common import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt similarity index 86% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt index bad85046..55f5400a 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist +package org.spotube.plugin_interfaces.plugin_apis.metadata.playlist import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val MetadataPlaylistAPI_SERVICE_NAME = "MetadataPlaylistAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt similarity index 82% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt index 92a3cd6a..9af1c667 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist +package org.spotube.plugin_interfaces.plugin_apis.metadata.playlist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import kotlinx.serialization.Serializable @Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt similarity index 88% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt index 5062f727..286bc19b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search +package org.spotube.plugin_interfaces.plugin_apis.metadata.search import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult const val MetadataSearchAPI_SERVICE_NAME = "MetadataSearchAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt similarity index 77% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt index c084341a..bf3ddbe2 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search +package org.spotube.plugin_interfaces.plugin_apis.metadata.search -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt similarity index 85% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt index d51cf510..d30c78d3 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track +package org.spotube.plugin_interfaces.plugin_apis.metadata.track import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult const val MetadataTrackAPI_SERVICE_NAME = "MetadataTrackAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt similarity index 79% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt index 178e8ded..cc34d522 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track +package org.spotube.plugin_interfaces.plugin_apis.metadata.track -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.Serializable @Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt similarity index 93% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt index dd4e947b..5a3f2180 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user +package org.spotube.plugin_interfaces.plugin_apis.metadata.user import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.StateFlow diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt similarity index 87% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt index 6d853510..85d96ba7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user +package org.spotube.plugin_interfaces.plugin_apis.metadata.user -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.Serializable @Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt similarity index 93% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt index e1dc6dae..951d3b95 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.scrobble +package org.spotube.plugin_interfaces.plugin_apis.scrobble import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt similarity index 94% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt index 202b398b..10e45bc6 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.scrobble +package org.spotube.plugin_interfaces.plugin_apis.scrobble import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt b/plugin_interfaces/src/commonTest/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt similarity index 93% rename from plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt rename to plugin_interfaces/src/commonTest/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt index b676d48f..ccaf9971 100644 --- a/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt +++ b/plugin_interfaces/src/commonTest/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.plugin_interfaces.plugin_apis.audio +package org.spotube.plugin_interfaces.plugin_apis.audio import kotlinx.serialization.json.Json import kotlin.test.Test diff --git a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.ios.kt b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.ios.kt similarity index 94% rename from plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.ios.kt rename to plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.ios.kt index 6eae50bd..4af9ff8b 100644 --- a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.ios.kt +++ b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core actual fun runPluginInitialized(block: suspend () -> Unit) { } \ No newline at end of file diff --git a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt similarity index 93% rename from plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt rename to plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt index fad98790..c6b5fc53 100644 --- a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt +++ b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { } \ No newline at end of file diff --git a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.ios.kt b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.ios.kt similarity index 96% rename from plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.ios.kt rename to plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.ios.kt index b69ed577..32d679ce 100644 --- a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.ios.kt +++ b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) { diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interface/host_apis/.gitkeep b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interface/host_apis/.gitkeep similarity index 100% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interface/host_apis/.gitkeep rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interface/host_apis/.gitkeep diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.js.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.js.kt similarity index 91% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.js.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.js.kt index c8276361..eee9e634 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.js.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.js.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core import app.cash.zipline.Zipline -import dev.krtirtho.plugin_interfaces.core.browser_apis.polyfill +import org.spotube.plugin_interfaces.core.browser_apis.polyfill private val zipline by lazy { Zipline.get() } diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt similarity index 93% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt index ba8b3596..98022b80 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { fastTextEncodingPolyfill() diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt similarity index 98% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt index 5684cd61..febf774c 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis fun fastTextEncodingPolyfill() { js( diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.js.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.js.kt similarity index 96% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.js.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.js.kt index 59a852d0..c10b677d 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.js.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.js.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) { diff --git a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.jvm.kt b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.jvm.kt similarity index 94% rename from plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.jvm.kt rename to plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.jvm.kt index 6eae50bd..4af9ff8b 100644 --- a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.jvm.kt +++ b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core actual fun runPluginInitialized(block: suspend () -> Unit) { } \ No newline at end of file diff --git a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt similarity index 93% rename from plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt rename to plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt index fad98790..c6b5fc53 100644 --- a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt +++ b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { } \ No newline at end of file diff --git a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.jvm.kt b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.jvm.kt similarity index 96% rename from plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.jvm.kt rename to plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.jvm.kt index b69ed577..32d679ce 100644 --- a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.jvm.kt +++ b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) { From 9db35e092b043456b5617a74de1488ef0c46a98d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 20:06:12 +0600 Subject: [PATCH 065/132] feat(server): implement port fallback mechanism for playback proxy server --- .../spotube/core/server/LocalServer.kt | 27 ++++--- .../spotube/core/server/ServerBinder.kt | 66 +++++++++++++++++ .../spotube/core/server/StreamProxy.kt | 34 ++++++++- .../spotube/core/server/ServerBinderTest.kt | 72 +++++++++++++++++++ gradle/libs.versions.toml | 7 +- 5 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index 69c9ae48..d0a700e9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -154,17 +154,26 @@ class LocalServer( logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" } stopServerLocked() - serverState.value = embeddedServer( - factory = CIO, - host = host, - port = port, - module = { configureRoutes() } - ).also { engine -> - engine.start(wait = false) + val bound = runCatching { + startServerWithPortFallback( + host = host, + preferredPort = port, + onBindFailure = { failedPort, cause -> + logger.w(cause) { "Playback proxy could not bind to $host:$failedPort" } + } + ) { configureRoutes() } + }.getOrElse { throwable -> + logger.e(throwable) { "Playback proxy failed to start on $host (requested port $port)" } + return } - activePort.value = port + + if (bound.usedFallback) { + logger.w { "Port $port is unavailable; playback proxy is using $host:${bound.port} instead" } + } + serverState.value = bound.engine + activePort.value = bound.port activeHost.value = host - logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:$port"}" } + logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:${bound.port}"}" } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt new file mode 100644 index 00000000..756cc73e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt @@ -0,0 +1,66 @@ +/* + * 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 . + */ + + +package org.spotube.spotube.core.server + +import io.ktor.server.application.Application +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val usedFallback: Boolean) + +/** + * Starts the playback proxy on [preferredPort] and confirms the socket is actually bound. + * + * CIO's `start(wait = false)` returns before binding, so a port that is already taken fails + * later on a worker thread and the server looks started while nothing is listening. If the + * preferred port can't be bound, retry on an OS-assigned free port (port 0). + * + * The caller must read [BoundServer.port] instead of assuming [preferredPort]. + */ +internal suspend fun startServerWithPortFallback( + host: String, + preferredPort: Int, + onBindFailure: (port: Int, cause: Throwable) -> Unit = { _, _ -> }, + module: Application.() -> Unit +): BoundServer { + val candidates = listOf(preferredPort, 0).distinct() + var lastFailure: Throwable? = null + + for (candidate in candidates) { + val engine = embeddedServer(factory = CIO, host = host, port = candidate, module = module) + try { + engine.start(wait = false) + val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } + return BoundServer(engine, boundPort, usedFallback = candidate != preferredPort) + } catch (e: Throwable) { + runCatching { engine.stop(gracePeriodMillis = 0, timeoutMillis = 500) } + // A failed bind surfaces as the engine job's CancellationException; only rethrow + // when it is the caller's own coroutine that was cancelled. + currentCoroutineContext().ensureActive() + onBindFailure(candidate, e) + lastFailure = e + } + } + throw lastFailure ?: IllegalStateException("No port candidates to bind") +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index ec6ef668..900d830a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -58,6 +58,7 @@ import okio.buffer import okio.use import org.koin.core.component.KoinComponent import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource internal class StreamProxy( private val httpClient: HttpClient, @@ -92,6 +93,13 @@ internal class StreamProxy( return } + logger.i { + "[Req] $requestMethod /stream/$trackId from=${call.request.local.remoteHost} " + + "version=${call.request.local.version} range=${call.request.headers[HttpHeaders.Range]} " + + "ua=${call.request.headers[HttpHeaders.UserAgent]} " + + "connection=${call.request.headers[HttpHeaders.Connection]} caching=${isCachingEnabled()}" + } + if (isCachingEnabled()) { val cachedEntry = cacheManager.findCachedEntry(trackId) if (cachedEntry != null) { @@ -152,7 +160,13 @@ internal class StreamProxy( } private suspend fun doStreamFromUpstream(call: ApplicationCall, trackId: String, requestMethod: HttpMethod) { + val resolveStart = TimeSource.Monotonic.markNow() val streamInfo = streamingUrlRepository.resolveStreamInfo(trackId) + logger.i { + "[Req] resolved stream for $trackId in ${resolveStart.elapsedNow()}: " + + "protocol=${streamInfo?.protocol} codec=${streamInfo?.codec} container=${streamInfo?.container} " + + "url=${streamInfo?.url?.take(120)}" + } if (streamInfo == null) { logger.w { "Unable to resolve stream for track $trackId" } call.respond(HttpStatusCode.NotFound, "Unable to resolve stream for track $trackId") @@ -319,6 +333,7 @@ internal class StreamProxy( var currentUrl = streamUrl var attemptedRefresh = false + val upstreamStart = TimeSource.Monotonic.markNow() // `request()` buffers the whole body before returning, which stalls the player until // the full track is downloaded. `execute {}` hands over the response as soon as headers @@ -333,6 +348,11 @@ internal class StreamProxy( call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) }.execute { response -> + logger.i { + "[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " + + "status=${response.status} version=${response.version} " + + "headers=${response.headers.entries().joinToString { "${it.key}=${it.value}" }}" + } if (response.status.value < 400) { respondWithUpstream(call, response, trackId, requestMethod) return@execute @@ -356,7 +376,7 @@ internal class StreamProxy( if (failure != null) { if (failure is CancellationException) throw failure if (failure.isClientDisconnect()) { - logger.d { "Player closed the connection while streaming track $trackId" } + logger.i(failure) { "[Req] player closed the connection while streaming track $trackId" } return } logger.w(failure) { "Failed to fetch upstream stream for track $trackId" } @@ -432,6 +452,18 @@ internal class StreamProxy( downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + val writeStart = TimeSource.Monotonic.markNow() + logger.i { "[Req] start writing body for $trackId status=${upstream.status} cache=${cacheFilename != null} expected=$expectedLength" } + try { + writeBody(channel) + logger.i { "[Req] finished writing body for $trackId in ${writeStart.elapsedNow()}" } + } catch (e: Throwable) { + logger.i(e) { "[Req] body write for $trackId aborted after ${writeStart.elapsedNow()}: ${e::class.simpleName}: ${e.message}" } + throw e + } + } + + private suspend fun writeBody(channel: io.ktor.utils.io.ByteWriteChannel) { if (cacheFilename != null) { val cacheDir = cacheManager.resolveCacheDir() val cacheFile = cacheDir / cacheFilename.toPath() diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt new file mode 100644 index 00000000..79e3a4c2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt @@ -0,0 +1,72 @@ +/* + * 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 . + */ + + +package org.spotube.spotube.core.server + +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import java.net.ServerSocket +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ServerBinderTest { + private val host = "127.0.0.1" + + private fun health(port: Int) = + URI("http://$host:$port/health").toURL().readText() + + @Test + fun usesPreferredPortWhenFree() = runBlocking { + val free = ServerSocket(0, 1, InetAddress.getByName(host)).use { it.localPort } + val bound = startServerWithPortFallback(host, free) { + routing { get("/health") { call.respondText("ok") } } + } + try { + assertEquals(free, bound.port) + assertTrue(!bound.usedFallback) + assertEquals("ok", health(bound.port)) + } finally { + bound.engine.stop(0, 500) + } + } + + @Test + fun fallsBackToAnotherPortWhenPreferredIsTaken() = runBlocking { + ServerSocket(0, 1, InetAddress.getByName(host)).use { blocker -> + val taken = blocker.localPort + val failures = mutableListOf() + val bound = startServerWithPortFallback(host, taken, { port, _ -> failures += port }) { + routing { get("/health") { call.respondText("ok") } } + } + try { + assertNotEquals(taken, bound.port) + assertTrue(bound.usedFallback) + assertEquals(listOf(taken), failures) + assertEquals("ok", health(bound.port)) + } finally { + bound.engine.stop(0, 500) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24df3449..4186c354 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,7 +55,6 @@ vlcjNative = "4.12.0" ziplineVersion = "1.27.0" ktor = "3.5.1" dnssdkt = "1.1.0" -kotlinStdlib = "2.4.10" runner = "1.7.0" core = "1.7.0" filekit = "0.14.2" @@ -148,7 +147,7 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization- kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 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 = "kotlinStdlib" } +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" } @@ -186,8 +185,8 @@ 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 = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" } -vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" } +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" } From 9fba2aa33d99f05883d225f5da86745ed18aaecb Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 20:49:16 +0600 Subject: [PATCH 066/132] feat(window): implement window state persistence and restoration --- .../org/spotube/spotube/core/db/Database.kt | 7 + .../spotube/spotube/core/di/Modules.jvm.kt | 3 + .../core/window/WindowStateRepository.kt | 89 ++++++++++++ .../kotlin/org/spotube/spotube/main.kt | 131 +++++++++++++++--- .../core/window/WindowStateRepositoryTest.kt | 85 ++++++++++++ 5 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt index d8319f8a..272aa8fe 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt @@ -77,6 +77,12 @@ class Database(val paths: Paths) { ) } + val windowStateDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_window.preferences_pb".toPath() } + ) + } + private companion object { /** Preference files written by previous launches; their presence means an upgrade. */ val EXISTING_INSTALL_PROBE_FILES = listOf( @@ -95,4 +101,5 @@ object DatabaseKeys { val AUDIO_PLAYER_QUEUE_STATE_KEY = stringPreferencesKey("audio_player_queue_state") val LOCAL_MEDIA_CACHE_STATE_KEY = stringPreferencesKey("local_media_cache_state") val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at") + val WINDOW_STATE_KEY = stringPreferencesKey("window_state") } diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt index d668c914..bc7fc177 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt @@ -30,9 +30,12 @@ import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.withOptions import org.koin.dsl.module +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.window.WindowStateRepository actual val platformModules = module { singleOf(::Paths) + single { WindowStateRepository(get()) } single { AudioPlayer(Unit) } single { JvmLocalMediaDiscoveryService() } single { JvmShareService() } diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt new file mode 100644 index 00000000..6bddb90a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt @@ -0,0 +1,89 @@ +/* + * 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 . + */ + + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys + +/** + * What survives a restart: the last *normal* (floating) size, and whether the window was + * maximized. The size is kept while maximized so un-maximizing after a restart restores it. + */ +@Serializable +data class PersistedWindowState( + val widthDp: Float, + val heightDp: Float, + val maximized: Boolean +) { + val size: DpSize get() = DpSize(widthDp.dp, heightDp.dp) + val placement: WindowPlacement + get() = if (maximized) WindowPlacement.Maximized else WindowPlacement.Floating + + companion object { + fun of(size: DpSize, placement: WindowPlacement) = PersistedWindowState( + widthDp = size.width.value, + heightDp = size.height.value, + maximized = placement == WindowPlacement.Maximized) + } +} + +class WindowStateRepository internal constructor(private val dataStore: DataStore) { + constructor(database: Database) : this(database.windowStateDataStore) + + private val json = Json { ignoreUnknownKeys = true } + + /** Returns null when nothing was saved yet or the stored value is unreadable. */ + suspend fun load(): PersistedWindowState? = runCatching { + val raw = dataStore.data.first()[DatabaseKeys.WINDOW_STATE_KEY] ?: return null + json.decodeFromString(raw) + }.getOrNull() + + suspend fun save(state: PersistedWindowState) { + runCatching { + dataStore.edit { prefs -> + prefs[DatabaseKeys.WINDOW_STATE_KEY] = json.encodeToString(PersistedWindowState.serializer(), state) + } + } + } +} + +/** + * Keeps a restored [size] usable: never smaller than [min], and never bigger than the + * available screen ([maxWidth] x [maxHeight]) so a size saved on a larger monitor can't open + * off-screen. A null max means the screen size is unknown, so only [min] is enforced. + */ +fun DpSize.coerceForScreen( + min: DpSize, + maxWidth: Dp?, + maxHeight: Dp? +): DpSize { + val width = width.coerceAtLeast(min.width).let { if (maxWidth != null) it.coerceAtMost(maxOf(maxWidth, min.width)) else it } + val height = height.coerceAtLeast(min.height).let { if (maxHeight != null) it.coerceAtMost(maxOf(maxHeight, min.height)) else it } + return DpSize(width, height) +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index 32cb1500..4a3148cd 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -18,18 +18,37 @@ package org.spotube.spotube import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState -import com.sun.jna.Library -import com.sun.jna.Native +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.window.tao.TaoScreenGeometry +import io.github.vinceglb.filekit.FileKit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.spotube.spotube.core.di.initKoin -import org.spotube.spotube.core.deeplink.ExternalUriHandler +import org.spotube.spotube.core.i18n.AppLocaleProvider import org.spotube.spotube.core.newpipe.NewPipeDownloader import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.systemtray.SystemTray @@ -37,19 +56,11 @@ import org.spotube.spotube.core.systemtray.SystemTrayService import org.spotube.spotube.core.ui.component.LocalApplicationScope import org.spotube.spotube.core.ui.component.LocalWindowScope import org.spotube.spotube.core.ui.component.LocalWindowState -import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.core.window.PersistedWindowState +import org.spotube.spotube.core.window.WindowStateRepository +import org.spotube.spotube.core.window.coerceForScreen import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SupportedLanguages -import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend -import dev.nucleusframework.application.nucleusApplication -import io.github.vinceglb.filekit.FileKit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import org.koin.core.component.KoinComponent -import org.koin.core.component.get object KoinPathsProvider : KoinComponent { val paths: Paths get() = get() @@ -58,9 +69,65 @@ object KoinPathsProvider : KoinComponent { private object KoinServicesProvider : KoinComponent { val systemTrayService: SystemTrayService get() = get() val settingsProvider: SettingsProvider get() = get() + val windowStateRepository: WindowStateRepository get() = get() } -@OptIn(ExperimentalComposeUiApi::class) +private const val WINDOW_SCREEN_FRACTION = 0.85f +private const val WINDOW_ASPECT_RATIO = 16f / 10f +private val MAX_WINDOW_WIDTH = 1440.dp +private val MAX_WINDOW_HEIGHT = 960.dp +private val MIN_WINDOW_SIZE = DpSize(800.dp, 500.dp) +private const val WINDOW_STATE_SAVE_DEBOUNCE_MS = 500L + +/** Largest [WINDOW_ASPECT_RATIO] size that fits inside [maxWidth] x [maxHeight]. */ +private fun fitAspectRatio(maxWidth: Dp, maxHeight: Dp): DpSize { + val width = minOf(maxWidth, maxHeight * WINDOW_ASPECT_RATIO) + return DpSize(width, width / WINDOW_ASPECT_RATIO) +} + +/** + * A 16:10 window that fits in 85% of the primary monitor's work area (screen minus + * taskbar/dock), capped at 1440x960. + * + * Uses Nucleus' Tao screen geometry rather than AWT, which doesn't exist on the Tao runtime. + * The work area is in physical pixels, so it's divided by the monitor scale factor to get dp. + * Falls back to the cap box when the platform bridge can't report a monitor. + */ +private fun initialWindowSize(): DpSize { + val workArea = availableScreenSize() + ?: return fitAspectRatio(MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT) + + return fitAspectRatio( + maxWidth = (workArea.width * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_WIDTH), + maxHeight = (workArea.height * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_HEIGHT)) +} + +/** The primary monitor's work area in dp, or null when the platform bridge can't report it. */ +private fun availableScreenSize(): DpSize? { + val workArea = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx() }.getOrNull() + if (workArea == null || workArea.size < 4 || workArea[2] <= 0 || workArea[3] <= 0) return null + + val scale = runCatching { TaoScreenGeometry.primaryMonitorScaleFactor() } + .getOrDefault(1f) + .takeIf { it > 0f } ?: 1f + + return DpSize((workArea[2] / scale).dp, (workArea[3] / scale).dp) +} + +/** Saved size if there is one (clamped to this screen), otherwise the default for a first launch. */ +private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowState { + if (saved == null || saved.widthDp <= 0f || saved.heightDp <= 0f) { + return PersistedWindowState.of(initialWindowSize(), WindowPlacement.Floating) + } + val screen = availableScreenSize() + val size = saved.size.coerceForScreen( + min = MIN_WINDOW_SIZE, + maxWidth = screen?.width, + maxHeight = screen?.height) + return PersistedWindowState.of(size, saved.placement) +} + +@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) fun main() { FileKit.init(appId = "org.spotube.spotube") initKoin() @@ -68,10 +135,28 @@ fun main() { val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) nucleusApplication(backend = NucleusBackend.Tao) { - val windowState = rememberWindowState( - width = 1080.dp, - height = 720.dp - ) + val windowStateRepository = remember { KoinServicesProvider.windowStateRepository } + // Needed before the window is created, so the one-time read at startup is blocking + val restored = remember { restoreWindowState(runBlocking { windowStateRepository.load() }) } + val windowState = rememberWindowState(placement = restored.placement, size = restored.size) + + // The size to restore into when un-maximizing; only floating windows update it + var normalSize by remember { mutableStateOf(restored.size) } + val currentWindowState = { PersistedWindowState.of(normalSize, windowState.placement) } + // Exit paths can't wait on a coroutine that is about to be cancelled + val persistWindowStateBlocking = { runBlocking { windowStateRepository.save(currentWindowState()) } } + + LaunchedEffect(windowState) { + snapshotFlow { windowState.placement to windowState.size } + .onEach { (placement, size) -> + if (placement == WindowPlacement.Floating && size.isSpecified && + size.width > 0.dp && size.height > 0.dp) { + normalSize = size + } + } + .debounce(WINDOW_STATE_SAVE_DEBOUNCE_MS) + .collect { windowStateRepository.save(currentWindowState()) } + } val settingsProvider = KoinServicesProvider.settingsProvider val settings by settingsProvider.settingsState.collectAsState(initial = null) @@ -83,6 +168,7 @@ fun main() { val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible } val onExit = { + persistWindowStateBlocking() trayService.close() appScope.cancel() exitApplication() @@ -96,7 +182,8 @@ fun main() { SystemTray( isWindowVisible = isWindowVisible, onToggleWindowVisibility = onToggleWindowVisibility, - onExit = onExit) + onExit = onExit + ) } } @@ -106,13 +193,15 @@ fun main() { if (minimizeToTray) { isWindowVisible = false } else { + persistWindowStateBlocking() trayService.close() appScope.cancel() exitApplication() } }, title = "Spotube", - visible = isWindowVisible) { + visible = isWindowVisible + ) { CompositionLocalProvider( LocalApplicationScope provides this@nucleusApplication, LocalWindowScope provides this@DecoratedWindow, diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt new file mode 100644 index 00000000..6a8ded1d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt @@ -0,0 +1,85 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WindowStateRepositoryTest { + private fun repository(): WindowStateRepository { + val dir = Files.createTempDirectory("window-state-test").toFile() + val file = File(dir, "window.preferences_pb") + return WindowStateRepository( + PreferenceDataStoreFactory.createWithPath(produceFile = { file.absolutePath.toPath() })) + } + + @Test + fun missingValueLoadsNull() = runBlocking { + assertNull(repository().load()) + } + + @Test + fun roundTripsNormalState() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1200.dp, 750.dp), WindowPlacement.Floating)) + + val loaded = store.load()!! + assertEquals(DpSize(1200.dp, 750.dp), loaded.size) + assertEquals(WindowPlacement.Floating, loaded.placement) + } + + @Test + fun roundTripsMaximizedStateKeepingNormalSize() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1100.dp, 700.dp), WindowPlacement.Maximized)) + + val loaded = store.load()!! + assertEquals(WindowPlacement.Maximized, loaded.placement) + assertEquals(DpSize(1100.dp, 700.dp), loaded.size) + } + + @Test + fun oversizedSavedSizeIsClampedToScreen() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(1800.dp, 1000.dp), clamped) + } + + @Test + fun tinySavedSizeIsRaisedToMinimum() { + val clamped = DpSize(100.dp, 100.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(800.dp, 500.dp), clamped) + } + + @Test + fun unknownScreenOnlyEnforcesMinimum() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = null, maxHeight = null) + assertEquals(DpSize(3000.dp, 2000.dp), clamped) + } +} From 62c1fe79027d0140014633ff14026da65679b7f1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 21:34:44 +0600 Subject: [PATCH 067/132] feat: enhance Android build configuration with stable/nightly flavors and update launcher icons --- .github/workflows/spotube-release-binary.yml | 38 +++- AGENTS.md | 4 +- composeApp/build.gradle.kts | 30 ++- composeApp/icons/nightly/icon.icns | Bin 0 -> 559221 bytes composeApp/icons/nightly/icon.ico | Bin 0 -> 372526 bytes composeApp/icons/nightly/icon.png | Bin 0 -> 119437 bytes composeApp/icons/stable/icon.icns | Bin 0 -> 536390 bytes composeApp/icons/stable/icon.ico | Bin 0 -> 372526 bytes composeApp/icons/stable/icon.png | Bin 0 -> 117053 bytes .../src/androidMain/ic_launcher-playstore.png | Bin 0 -> 65676 bytes .../spotube/spotube/media/PlaybackService.kt | 14 +- .../res/drawable-hdpi/ic_stat_spotube.png | Bin 0 -> 1209 bytes .../res/drawable-mdpi/ic_stat_spotube.png | Bin 0 -> 737 bytes .../drawable-v24/ic_launcher_foreground.xml | 47 ----- .../res/drawable-xhdpi/ic_stat_spotube.png | Bin 0 -> 1699 bytes .../res/drawable-xxhdpi/ic_stat_spotube.png | Bin 0 -> 2871 bytes .../res/drawable-xxxhdpi/ic_stat_spotube.png | Bin 0 -> 4073 bytes .../res/drawable/ic_launcher_background.xml | 187 ------------------ .../res/mipmap-anydpi-v26/ic_launcher.xml | 7 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 7 +- .../res/mipmap-hdpi/ic_launcher.png | Bin 3593 -> 3625 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 7209 bytes .../mipmap-hdpi/ic_launcher_monochrome.png | Bin 0 -> 4230 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 5339 -> 5388 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 2636 -> 2037 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 3788 bytes .../mipmap-mdpi/ic_launcher_monochrome.png | Bin 0 -> 2401 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 3388 -> 3073 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 4926 -> 5623 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 11417 bytes .../mipmap-xhdpi/ic_launcher_monochrome.png | Bin 0 -> 6202 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 7472 -> 8167 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 7909 -> 10671 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 22443 bytes .../mipmap-xxhdpi/ic_launcher_monochrome.png | Bin 0 -> 10599 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 11873 -> 14929 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 10652 -> 16763 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 37001 bytes .../mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin 0 -> 15843 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 16570 -> 23111 bytes .../src/androidMain/res/values/colors.xml | 21 ++ .../composeResources/values/strings.xml | 1 + .../spotube/modules/plugin/PluginScreen.kt | 16 +- .../kotlin/org/spotube/spotube/main.kt | 4 + .../nightly/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4072 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 8272 bytes .../mipmap-hdpi/ic_launcher_monochrome.png | Bin 0 -> 5025 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4966 bytes .../nightly/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2237 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 4340 bytes .../mipmap-mdpi/ic_launcher_monochrome.png | Bin 0 -> 2801 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2801 bytes .../nightly/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6288 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 13453 bytes .../mipmap-xhdpi/ic_launcher_monochrome.png | Bin 0 -> 7449 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7587 bytes .../nightly/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 11843 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 27057 bytes .../mipmap-xxhdpi/ic_launcher_monochrome.png | Bin 0 -> 13411 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 13950 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 19066 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 47078 bytes .../mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin 0 -> 20654 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 22211 bytes composeApp/src/nightly/res/values/strings.xml | 21 ++ .../AppIcon.appiconset/Contents.json | 6 +- .../AppIcon.appiconset/app-icon-1024-dark.png | Bin 0 -> 182897 bytes .../app-icon-1024-tinted.png | Bin 0 -> 87930 bytes .../AppIcon.appiconset/app-icon-1024.png | Bin 67285 -> 182897 bytes 69 files changed, 144 insertions(+), 259 deletions(-) create mode 100644 composeApp/icons/nightly/icon.icns create mode 100644 composeApp/icons/nightly/icon.ico create mode 100644 composeApp/icons/nightly/icon.png create mode 100644 composeApp/icons/stable/icon.icns create mode 100644 composeApp/icons/stable/icon.ico create mode 100644 composeApp/icons/stable/icon.png create mode 100644 composeApp/src/androidMain/ic_launcher-playstore.png create mode 100644 composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png create mode 100644 composeApp/src/androidMain/res/drawable-mdpi/ic_stat_spotube.png delete mode 100644 composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png create mode 100644 composeApp/src/androidMain/res/drawable-xxhdpi/ic_stat_spotube.png create mode 100644 composeApp/src/androidMain/res/drawable-xxxhdpi/ic_stat_spotube.png delete mode 100644 composeApp/src/androidMain/res/drawable/ic_launcher_background.xml create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/androidMain/res/values/colors.xml create mode 100644 composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png create mode 100644 composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png create mode 100644 composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher.png create mode 100644 composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png create mode 100644 composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 composeApp/src/nightly/res/values/strings.xml create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 6df4bf5a..bfd66bb2 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -141,11 +141,16 @@ jobs: EOF - name: Assemble Release - run: ./gradlew :composeApp:assembleRelease + run: | + if [ "${{ inputs.release_type }}" = "nightly" ]; then + ./gradlew :composeApp:assembleNightlyRelease -Pnightly=true -PbuildNumber=${{ github.run_number }} + else + ./gradlew :composeApp:assembleStableRelease + fi - name: Rename APK run: | - APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1) + APK_FILE=$(find composeApp/build/outputs/apk -path "*elease*" -name "*.apk" -type f | head -1) cp "$APK_FILE" Spotube-android-all-arch.apk - name: Upload APK @@ -194,7 +199,7 @@ jobs: run: chmod +x gradlew - name: Package Linux - run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm + run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename Linux artifacts run: | @@ -255,7 +260,7 @@ jobs: run: chmod +x gradlew - name: Package Linux - run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm + run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename Linux artifacts run: | @@ -308,7 +313,7 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Package EXE - run: ./gradlew :composeApp:packageReleaseExe + run: ./gradlew :composeApp:packageReleaseExe -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename Windows artifacts shell: pwsh @@ -357,7 +362,7 @@ jobs: run: chmod +x gradlew - name: Package DMG - run: ./gradlew :composeApp:packageReleaseDmg + run: ./gradlew :composeApp:packageReleaseDmg -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename macOS artifact run: | @@ -389,18 +394,35 @@ jobs: steps: - uses: actions/checkout@v4 + # 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@v4 + with: + name: maven-local + path: ~/.m2/repository/ + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Set release variables id: vars run: | if [ "${{ inputs.release_type }}" = "stable" ]; then - VERSION=$(grep 'versionName' composeApp/build.gradle.kts | head -1 | sed 's/.*"\(.*\)".*/\1/') + chmod +x gradlew + VERSION=$(./gradlew -q :composeApp:printVersion) TAG="v${VERSION}" echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "is_draft=true" >> "$GITHUB_OUTPUT" echo "release_name=Spotube ${TAG}" >> "$GITHUB_OUTPUT" else TAG="nightly" - VERSION="$(date +%Y-%m-%d)" + VERSION="$(date +%Y-%m-%d)-${{ github.run_number }}" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "is_draft=false" >> "$GITHUB_OUTPUT" echo "release_name=Nightly ${VERSION}" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index ba88dadf..44b1662f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ ## High-value commands - Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. - Run desktop: `:composeApp:run` -- Build Android debug: `:composeApp:assembleDebug` +- Build Android debug: `:composeApp:assembleStableDebug` (or `assembleNightlyDebug`). Android has `stable`/`nightly` product flavors (nightly = beta channel: `.nightly` applicationId suffix, own icons/name in `composeApp/src/nightly/res`). Desktop icons come from `composeApp/icons/{stable,nightly}`, chosen with `-Pnightly=true`. - Module checks: `:composeApp:check`, `:plugin_interfaces:check`, `:js_plugin_example:check` - Focused tests: `:composeApp:jvmTest`, `:composeApp:iosSimulatorArm64Test`, `:plugin_interfaces:jvmTest`, `:plugin_interfaces:jsTest`, `:js_plugin_example:jsTest` - No lint/typecheck/formatter tasks are configured; `:composeApp:check` is the only aggregated check. @@ -70,6 +70,6 @@ - **Adaptive Components:** Use `AdaptiveDropdownBottomSheet` or `AdaptiveDialogBottomSheet` inside `commonMain` for adaptive layouts[cite: 2]. Do NOT create platform-specific `expect/actual` variants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2]. ### 3. Verification & Diagnostic Loops -- **Post-Edit Verification:** After editing Kotlin code, validate compilation by running `./gradlew :composeApp:check` or `./gradlew :composeApp:assembleDebug` via MCP[cite: 2]. +- **Post-Edit Verification:** After editing Kotlin code, validate compilation by running `./gradlew :composeApp:check` or `./gradlew :composeApp:assembleStableDebug` via MCP[cite: 2]. - **Dispatcher Integrity:** Never remove or bypass `SwingMainDispatcherFactory` in `composeApp/src/jvmMain/.../core/coroutines/`[cite: 2]. It is required to prevent `compose-webview` from hijacking `Dispatchers.Main` away from the Swing EDT[cite: 2]. - **Live Preview Iteration:** Use `compose-hot-reload` to evaluate UI layout changes headlessly without forcing full Gradle rebuilds. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cab6974a..3a242623 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -43,12 +43,24 @@ plugins { val bundleId = "org.spotube.spotube" val version = "6.0.0" val buildNumber = 46 +// Desktop icon channel; Android picks its icons via the stable/nightly product flavor. Use -Pnightly=true. +val isNightly = providers.gradleProperty("nightly").map { it.toBoolean() }.getOrElse(false) +val desktopIconDir = if (isNightly) "icons/nightly" else "icons/stable" +// Nightlies are numbered by the CI run (-PbuildNumber=, or GITHUB_RUN_NUMBER) so every build is traceable to its run. +val nightlyBuildNumber = providers.gradleProperty("buildNumber").orNull?.toIntOrNull() + ?: System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() + ?: buildNumber val targetName = "Spotube" val homePageUrl = "https://spotube.org" val repositoryUrl = "https://github.com/team-spotube/spotube" val appVersionName = version -val appBuildNumber = buildNumber +val appBuildNumber = if (isNightly) nightlyBuildNumber else buildNumber val appRepositoryUrl = repositoryUrl +// CI reads the release version from here instead of scraping this file. +tasks.register("printVersion") { + val appVersion = appVersionName + doLast { println(appVersion) } +} val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/") val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest" val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin") @@ -297,6 +309,19 @@ android { versionCode = buildNumber versionName = version } + // stable = production channel, nightly = beta channel (own icon, name and installable side by side) + flavorDimensions += "channel" + productFlavors { + create("stable") { + dimension = "channel" + } + create("nightly") { + dimension = "channel" + applicationIdSuffix = ".nightly" + versionCode = nightlyBuildNumber + versionNameSuffix = "-nightly.$nightlyBuildNumber" + } + } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -382,11 +407,13 @@ nucleus.application { homepage = homePageUrl linux { + iconFile.set(project.file("$desktopIconDir/icon.png")) modules("jdk.security.auth") debMaintainer = "Team Spotube " rpmLicenseType = "AGPL-3.0-or-later" } windows { + iconFile.set(project.file("$desktopIconDir/icon.ico")) shortcut = true menu = true dirChooser = true @@ -394,6 +421,7 @@ nucleus.application { } macOS { + iconFile.set(project.file("$desktopIconDir/icon.icns")) notarization { } diff --git a/composeApp/icons/nightly/icon.icns b/composeApp/icons/nightly/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..15e7c52a7e7d06c98f4ad567e7751160fda13d85 GIT binary patch literal 559221 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

plLJ7YGMGQG;(r0mo9&w>Xni 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Π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 z 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[PE8TQ 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%0Df`MvDm+bsqBnN;Z?@z;@p4uJ= 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 iclQ6o|2sW2kAoPjt+K-e?s)E zUIekd{LPCs$xE!Lpe8SB%rm|egF0; zi)(Ui+CW=q)387KLZ6UFV_SyRKPn$-;K`@~^L-d6Hc_Y6NDh*mBYBhLPbFWJJSh2r zZn`j$te5Hgo({}nW=x=>}A9T*BY@~rFvj*%3hw*)Db?#6JuZaAf z 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

R*~_}lMAWlziRp!mLj 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

wJIJ$m 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 Ho;UvoiqclM literal 0 HcmV?d00001 diff --git a/composeApp/icons/stable/icon.icns b/composeApp/icons/stable/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..7110d55faab7bc2e44d3168d06772373bccdd07d GIT binary patch literal 536390 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 z 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

NA8~dnYM|=AStdLOXrk+kVHBzjfekJm$nPc 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%0AqYh?D~HO;7QIfA7LZBK*7_JV0MKX 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∨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 literal 0 HcmV?d00001 diff --git a/composeApp/icons/stable/icon.png b/composeApp/icons/stable/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a59c7aa900e1976764e3347d746744c04293d06 GIT binary patch literal 117053 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/ic_launcher-playstore.png b/composeApp/src/androidMain/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..5fd7f2f3ce161ac46ad2485c79bf265bac1d767e GIT binary patch literal 65676 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 T;@SYwB$A4PrhNU|j}iX|lq#Pk literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt index 4a6cd6f7..49b55ac4 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -29,6 +29,7 @@ import androidx.core.app.NotificationCompat import androidx.media3.common.MediaItem as Media3MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams @@ -41,6 +42,7 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import org.spotube.spotube.MainActivity +import org.spotube.spotube.R import org.spotube.spotube.core.audioplayer.AudioPlayer import org.spotube.spotube.core.audioplayer.AudioPlayerInterface import org.spotube.spotube.core.audioplayer.AudioPlayerQueue @@ -75,6 +77,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent { createNotificationChannel() + // Media3 posts the real playback notification; point it at our channel, + // ID and monochrome icon so it replaces the placeholder below in place. + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_ID) + .setNotificationId(NOTIFICATION_ID) + .build() + .apply { setSmallIcon(R.drawable.ic_stat_spotube) } + ) + val sessionActivity = PendingIntent.getActivity( this, 0, @@ -87,7 +99,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Spotube") .setContentText(runBlocking { getString(Res.string.notification_playback_service_text) }) - .setSmallIcon(android.R.drawable.ic_media_play) + .setSmallIcon(R.drawable.ic_stat_spotube) .setContentIntent(sessionActivity) .setOngoing(true) .build() diff --git a/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png new file mode 100644 index 0000000000000000000000000000000000000000..3366b209d07441c631b323fe195133b9a30bf5bf GIT binary patch literal 1209 zcmV;q1V;ObP)lBVNVnmW$BAOAN08)pvBGw&2Fyl{Em``mNS{XggXyp<#_r_r7R zP374S@5X_Z!1cf=(DMFS;0W+5FcIj7fZqQFNSPWKYNlcNjfd*u%r``CewZ|oZS(4 zHr`t`7Z`L50;z#p}$Ymm-M{-SO&XxWILku)}oaXu{R zXgU}9?P~J6Ixsg&nndhciQ-Ezhz>(i%wz()phGo|1H1o$5%;k+i*m$uUWH znx;Jk#(&uK-O$;7QmkpYQ?(xK@YyO`EFjWRrHda^?N}#@! zG~a;RgMyp)+ooS2X?Flqfc-9MSq7Bj(71zO_PRHLX$QNmJ*Fv^ltkM#{S@#z^=jTt zNZOp#U7q)Pe=dABr~7B9tV(Gk=e06Az%{@N4tgaO$mUq^Io0SGvd%1N8!3$)s^(1q^;rI&yq%yx)z6EDA!S& zBu&+*uLQ`o(L3)$25!oLR@o-XSH1$gA8O`UkPWK8^STon zgoWn2GsUA-8PKRbqyo+Z?*_5XMH&%6zh|jO$M$Sa6PAt@$kNz0bMX*>AyCnYOjd4I|o3d22nKv zxF5J8*x2cu%YL&?$4s=tAg8R~%FGvqGYg%Mx(zBSXIkOiq|4W51JIN1bY~zw2z(tx zx)ONJZA|PklrC-!@U*^@b&>RZ;AN|C*4uq${$$A=aLX)FhB9bmvw%_V)T z@OiAJCFb9D;C;2O$S4p$rTH+&K_a*%jQb+7aUk~YLzodN)?Ndk66S<77>*cpd z`bN@pL+Mc7?{V2sd6ts(sYI8%muX2~NV;cGksm<~OIj!Ct%k<$yn#oCrKZ{Bp1*Fq z?M7iEKQZ9Fj@o=6%<60kV>!;lgh$ENNFK<`cIff1i?a!SO_8* z{6G-YpcW#*CPEQ-Iuha)?>+-fr4)1O9~+>`4s)Y8=%wsDX;~s0vWIm z*aeg=UjdE-^ZuPhgUv!SVdL8d3)ll30(t}SI#4s=6=RcDAu4&kAZb%l>MtL^CB2gL zLefIm&vBcQMhdowaZe;wC4IGG-E&RS9Z7xRFjYxcC2bV2^OE)ldkun$=j#S;OUfku zk~CIGfOwH-C9N5|ZQZG8Gb)xPy*0RI@Py^<(QV_~y`Bu7W|Cev^8iVQC9N7HNzYrP zZ?klK7VczO(!pF7v*-Y@Hx$bk;3RMcsCqvMnN|f(11F5l)3XccE@n~U!pcV$Jdvb1 zN!`Kq9!VbyMHlf$b1!ob1oVWW%1^om)PVuum~rxY(6ZG~L#fx@c_eaz-M~$+^Xl9H zE&`uoWrmRfAAoV-PeJ#V3$sBm0t|aS<5Q);J777qkYZc;X_C}c{nWByU^Eg8*}xny z<&!9c@4p@yND_NfvF>}zhay46z**o2&|#&lMLUn=d9P{*7J!$wRfzU#p00000NkvXXu0mjf`8iG| literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 5c58c8bf..00000000 --- a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png new file mode 100644 index 0000000000000000000000000000000000000000..c6c20ea42bb75044d1a1a6cb985e0e2222d8ddc0 GIT binary patch literal 1699 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 bBjfllQ_=a^uHvAT00000NkvXXu0mjfM{}G8 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 9faa29bb..00000000 --- a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml index 2ad9808b..8fe69f1a 100644 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -17,6 +17,7 @@ --> - - - \ No newline at end of file + + + + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml index 2ad9808b..8fe69f1a 100644 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -17,6 +17,7 @@ --> - - - \ No newline at end of file + + + + diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png index a571e60098c92c2baca8a5df62f2929cbff01b52..a5172fdf87fc83239bd0f8fd49f9fa4d56eeb631 100644 GIT binary patch delta 3624 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 D5!fX7 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png index 61da551c5594a1f9d26193983d2cd69189014603..f454ac1a970458da2cd72fe1e6bea98acedaec26 100644 GIT binary patch literal 5388 zcmV+n74zzeP)2O^g~86_qWZY|So6L$mZ&-LZ;%J zefM|o{odcbPxx>y3AI9(2dHfy_(VTQK#X0icZ~fQ+cW^$-!(wl1*JoumjFY7;kNY$ zs)4ATQwlf?>;<+1TkLB~s2}$|0{)=^WNhD=z&MkEnZP-~$-rRyy<)FDz}vuIfj;~KK?afq$_1VPK5cy`Db~4^Uh15*y&GS! z@AK^KAkA_C7m*0yq8bBgq3U2m;r;K@xN`%0 zx30q5yWTq19T-XDX*!3;koQUf5#TA{@{m=1UjWL2v%qex?0^OQMF)`>5J$Bf%EmJA zl-Z0wYZmoGMxY9Zn)Y*`aThHI8tG_n0X$;yGRmuJ=ubiQO(HI9B-(mfVrEFgQ z6dYKEa1bPkYG+8I3WD@2;2OK8Od*h@7vNkEJZRs$J#$jWN=+%MRZwvXC(ZmEr_DGI zRfmSPZ?JjQQku4}h34ImIRtbD<|}qBeBDi?k{OmQd5mqZFNT(l zKs(}qF$NLiFH4UsPQ+Iso~L+e^?7V`DD|hzVaBCbQdQH3#Sh=Z-sSfp?6DxF3T?Nb zPP!CW99Uzo0m^FF2(ZF>$(j7#sdQ~EAr4YapGh}!#jUsV(o>6B^Td6SSdTbKjB!u| zFXMn0@s)I>6r8xFoJmM`fY;&cR5y)i3L%Y`i9lpHW6qn;tWRITqYLk(Y1Q4p;m~%| zo?=S^8~{!Mb_YK16+l^Je_(xyBDYoqBPA$9nf>)gXxiVzn-6~tCwd4~MKNfq1WJcc zHgqC`MopyukP%e%?MF0Th6tpSowT<#bGT_Q?{44BJ8!>4df#gpuLX>QF%iDGZK_ zm=ZGDhw6!!GHTq3R8~eg(0DtmUV4s^6HepoIhWFzXlLbLpQT~-3#9g}gY;gYEwD8N zi1~D?as=_pGEk2hH{DO7y@faa{0w6!Pi6A7GfAe>tetWt@4k93Vlt@b6&s<=Mken9 zp0QTqC{(2f={e8-uz>WKF011!=2}Weeva$!Sio~n{E1x+4P1ET6^tKS&F(!Z{`~vj zux05Z(6JF{#fWcxv}-|PC+35$3Q7iX`ZMUvFER78*E4ct6`Qstc;b=YQeIKcC$FB% zuYdeQnpQo4NE7O{hJpwMx7t^LpA}=(g5itji=z>ar2rLb%wUz8YxKIGwd<)@Kd+Qr zru6@+&b@P=&i(EdrS=Ae6V;f0DpIYGi$+8O{LH5$NQpv)LcLOQvCg`6xqklV2{j`Y zD6xxl&DWT2@k~1Ul$p0Am+bqQ=xJ&Qv}B zU)JmS-`J#-T&~p3*Lgo^(u?odpu{gxk%20vuLUM%fpBuL_%NaMf%AD=ox&8IHvdJP z|BDWFhu)?{FVd+uy{@0Rvr&m(sK)fSV?=B}1tS0oTv1GTBn!|(Ay%BHISMfqsZ~!+t|76apJ>IXZrbb zc>KS91g*rE=Uk>|gmbdv@*O)bp-FwF)Bz z9RKdh+O;M>FF@IAPGK7<-8^SX$8zel^H{NT2_#zh*hN>dW&Ju1u6Yz=I*Rphi@1*H zR@F|#bdqTJBWsqw!r2$kgG`F$&n@Dl)21NRC!^{F0uE9P<+>p3RR;k|TUv2;ae*a4 z#6dMm&Da@4;w5Zb@fV^)#xZi-B$hq-E6b76q1erPy^ib(&r2e*pVyZ>%)nv8DI0Y< zjcb>Z>`XCe(kDH0(V^+bEpNzVY;?S%tWGV+vZ?AhLc?%Tnr zGiGvde5OC`lFl-FlH~tO8aN_vW*!;KUkbWm1F}=xC=uHSqt%KRbrq#|H!HI8azh#)qgzj}!G&pmG4^mDTLsz5%LgsqEX2 zof}sHooF`_YXVK)12`rSkP|!6H1BL)O<83%rfvw0yEYPym*Dmt>d!%X6^0(4@6a#1 z#fqD#_^baWJ!w@yUhrwm`o6$ta$Y=90tj9CI3#Al=neB53jA6^2)wsGHKH7 zEr=6CB#NqwY6R6Nsu6^kP2wn4lCt1LF~lIym#PnvQ1|k3+LT{aY{%gQz%eY8mIZ-UKZW6faH$H zEsO8T{M+~vWth6zTzJz01`ZugTU#gUc|;>FB59uf&4cV&@jQYeB8b&F{%fz3J+dNx z6fu+H&atJNqp0cwuU?$?bR6Rfr6-?$fW79LxNm(th4`RJ^dCBc2N!%9_WS{{fQoV1@Z@#|j~d5~ z|9my+6qL-F!;6%W#>LBcAiX}8@5UnB5ICL1OOFFuLfY-i*s&!zoX)COmh#8dD>-?_ z`Sk182jMV~Mm1$cULoB?dwVH9iH1iTD%qb`DK3gfu6 z9@s--)9+c^@H^H%JDTb9zRG3uKF5D=eS_4lMF<^emvPd3k|Wl7w1xek>}(o)2FO?j zA3-A{nRD}Z*uH5KuiXC)OyV^h(*)9v5f8jzijdbu4bY73U5~+gw*82GRXc`y{yCa- z;5vseZLhKPp4(WzdJS`LU4WL12Z;u3Qjb?tAoo$j0BtL@y?lZiW9Zpe)3>IM7av}L zW47XD+VL{&pdFwYyZmqfJ-aBihlEEse&f)G6_5{fn&Du(u3c^bzH_@ z_yttU3xz+jL+lKOe?lA6yj76FtAI0%GiF}S>(4&{tt(NIe!NF*yhqheL38Lm4IG^c zy-N|$-1<}lA!becKxqm|uH%J8k8s+I3!r4QCH^Bl&BQhnl?D5lFt>R}(R z%EiU9qrGkQP!6prmR>m24jDtDy_L?!jfkqRSAxK(_Fz3V2XF=Sk!lZpW|tO>5Zh3Q zc<7<6G&R1HSJ`5cPyOIoYMT$%ZcC@t; ziNzsOimEv}$|pga>% zQnokEl@7BJcsX42Ve$m8@5?JcCUxzG1^mm+_no@NkfX7!T$={IOF!)M)0CS8H!mV(56pt>wZI38m_gw8~} zAB8MfArQ)06v@%2*nd7SNE9bhMkZ6u$O~_wroNulOYTFY1y8$W|7J;g3w|C{&fIC! zgJaPK=JxJCJW^!i9e9Z+ANnbmeExa{P5VbO={hi_7%9ORH*}ieblZYQwu=;D;xpi- zNqY`>sqWiJvVnYq7{N#koN_X$dWOunm5b+H!Quygj2?P5kT2wg1>pzy#d;wz$ugqla-_|0xNC2TEP=P2-b<1Ms_W|i--4aW!9B9am_b>%AOtX z&~I=J2lh6i#BrQR*CBym$e1!B(HNOjXMu!w?NSk%XrOiTCLo3}5g#BCj1$50N>EQZ zz)R2AGdu^WYo{Kv|D+6L1TD&X7&S z_OnI&dG4ca<3lX^-V0QWK8GP=Pp6}E6yr~sK>3*2w61?5U|uU3JNp_O2M%mmX=SPB zD6fcbc?M3tg?MFuwmkoczx!43ya;B%G)7ODz>bC;?B4no%a`0w^ZLJlw+)dF3r;t# zn9V`>p7ncrOuOq%ru{v@P2I~7Y+kfP5F&VLbDkN@q$|I}lv(GpY0Wx1k_qsXzIF8s z7&e5*esLH3S1d$0gm&qMd;qziaq3UMow>K%!On(l>~GwQ5irJO)PxDFU%i@_@4Fo` zn}BwVq|mNV?@{2mmQGy|kQ{w400qs)68zfGK+bf~_i$}8XADmUQGN1djGA;7ZnO*$ zp{;oz>t246^sZ+?4%nQbLW!2ybFmuSK^HP%`bGYMpa|-v*tYIX_Pp_1jJF*$g_5$~ zc{-dgot^VKUhhh0U*jJnYkuV5l<*bl9wqqpV7SL@oa` z^+1FY*2lNA;#V^MWRsD27^$;;3j-+^~wL-l~ zi^-aG2h)uS*yLgLda57Gg7Xf3YV%u#>N9vBig;`Ai~IAGVumwSontAh?4hJHSARMC zOxo8qfy4~wgDL}$0uOM^#k|M01vT@Nft&;9!>E=2l08S#53m%xP_}CJ{el9lfe)&{ zcY=+^Y4H`%#|;>@f%kLx0IQvgsh`Kvk{zLKcRZyk{PczWz#V}s6&F4_kEaMGqJJ|4 zRPebS-uv*Bg|LJ+0^he!l=;X!mZH!DPO2@}`D>1vk4)(4F6qF*TDGMFVB~xL$D8=| zZ2gbdgH7Z(>ZTySnTVgDJ{v!aw9hg2-C|Al^ET@Fdcf&%j>l7}mtgB1~;DIdf9D`ugwhA#eG} qFFic4t=RAVzXPb)xqKwH%>MzHsc-`Zz|VdF0000Id|UZ0P}EI-1@)I=X~DGdw1?T_xsK{_uTvL8wG`@xdHSL zi(gOK!kzzrvteWHAo2y%6u%c~FYnJ<{N`T=3@w2g$1Fm|W?3HbvT3QGvT;S=yZYsV z;Ux5#j?uZ!)cIU&lDjT_%=}{Tn4nc%?;kSe8vq_&%eGAXoY=)gfJHN3HRxZ>B(Z_MschsoM6AUCjPu&A03`pU`P@H& z-Hldo)2LhkOv(g+79zsWLK6F$uY^-8!$ow=uuO2jh2SxRvH;PPs;xr%>aSRNI!<*k zq54?efxFGi!}O%x@0qhGX;;FAnHp6DCoZk~0VY&zmNZ7(K!PJ_APP1drc`bP>0_;h z&Qm$bcWJm(}i`WLgp2 zB!Saf;inDgfjrc$$+TEt@mPcR1IsBF%ve$XBbby0fpkyuOahYhptv_F4TPl^cFuY% z?j|wKCAHsATwcEiKD!!=-Rcj*rL{kREWvXSay1%O)$IkoG9;U>9D$AX2iq+}=c!zK zW#~F|y=6S-m(=bSuBh7sp;w||;ji02=~j1>n56y%KZ-d`CU}*Vr4Kbx#$l%nQktf zay7|dPxqqVP#g?4KFBTpC4g94a7d(I?Axdoz50FWHg^b+VQIjj*168V!-BZvwln~A zbKH-RtH}*WGN*#QmN8LoJ=px$01}Vc?i>8J3A9hHnIyNX`EfxD=_YXVIKs{VT3Ndn zW>tOBQlZBH$fP_7=2U+P&b2>w91zzwom{tMxdOJt%p6O<(sru*9vm-yM{=LrGg*A; zdzO^ZUi!GSIH4T8kpm@-mto`OgS_RuFCT{W^#^#*lhAo8$9JBR$l9jsaNtH3yDncj z9=-2VI~SII2{y5Q#*d6e5)(5m5qxJ>5ez6o)AC@Dmht5wuo5#@bKJK+ClNCgSImHK z-n$L4f1hQ)kyUO%%{MT;DuTBj5;{-iWSt||N^Q6Z*Y7p3>zTDvk2$AzYh73y(Ykaq z-S$a`7~Y)6@=WksXsXwxd#=vLpuN{KnDUhFcejffqj+47gj>yxu;Skx*L=&ijF8^lE3`V9ohnj~S&~kFu#to{@S-dohp8hv1H|3H&ftNS7f~Utf0s z-0Ba3@0BRndhI0axt07RCPdAk(OH`c?f>Mvkw)i#6?2gwcRS#Z7G zd>2F_5wA3$3sv9!1Cnl?gV3unFu8II%&++xD(_x{jN2uw{;mRg;AZ(A*EBq*^_OPS zqW3b$^)#DVy#pT1?REno`cCElZvG#G)QHy99*{=~0lSF3y@HHeTsgFs+5^r|WbX5XGTV4F1VJhg!y=hf7Reuqp}5 zpjo-u)jNf=s&|4cp{$jH>RjCOm6?Yz;^2*JxF>3UtZ*dKh{2k!N7v=kX)dSt9Dcop zb81lcyzm@k@zO&sTre7HI`lsiOGC;R*6af7$}J)ahO)%EGMpu4HrV~jI&WLG9e&21 zsJmTC9+#u*QYRowFVdIvCjDi%>vNHH^;Vcw_<5!BNaa2c12vZv4G*(@+qhJ4jaHo2}dFnxWlf-cFM)5Co`@Hf~jXV|1r?XR4QTQ0IB`3a47oVt z|6g6V5B_<=meX43`m1qB(K;T<3&^(kvxbr0HY3{r`e4_B5m;#>1JsFb9^)44eq||r zPuL7M8yn#EKX0t_p#Y8CWhr{I@fJ*t_J%S09bnu6C)j^6u}gryx)1{z z$5(=Sv@^^~4S~O!WMB72Qv<9l`<`YFI~IeALT?Y=U_MF;khm8cvUXB`qZ0oP2Wc83 z#osChA)h-mVaA)Z1=J9Z_Mv4EQKU`0Hs=d~uWLHHTj8F9fi!(vsQuh;Y9yGaXi_p3%9HylQ<{^u|E!Jpr zY4t0U3I+e|NG9!Y>09{qPVF-dsPK9j%*YIZDH(y_R=OYc-^rUv&#w9c?Be_n6N?s8 z9^Am}C9TAD-W?gNlC}N*&tK0ppev0xU{3z$pqt_X^K-X=L7_MAVAb%vKN#(G4ki|| z2CFZAwC7VR2B_UZ-$Otf>JRYdBF~DDeyfUhfnJI$1Eib25%kY`Kj__9fTqtCfnZSN z3+h2LXA+B+vx;J0>)HR4aYLq;ZoMM!gxQvBC!T3I5(z4a1ie%O6wUzYWD+DFsT?SP zO_=Fqx?LS;{=o=h(dLy0j@WC~g~8Fxg5;QT4XloWxSBkOtLCIeEb%q@kX~C136}~W z{!;!!sV!(Bsr5yWTz3}Y>+pMBAtcndmE_Askap!)NVt3&60XRQ-_JnO?`I+V+IdLC z&xu#1<7WJTkCaZW%6ugjd1<_`8UKkBlY z0Le3HPfsN^POO44|8)?{0Y@fde{uqwC=bv&v>e7pE@q z8(`eg?mj^_Z1R%;MZ&a)J+NoLmJOajThV#;*a*1Wppyfh8O(*koU0dg@3+iTmx-3%pq!1D#A~P}?85fI(%ICB387Z+3225a;)w{qpIRI>qdBW1z zFqn4S2W*aeflag*Oo{OpORNt}IpG6SPx^vWVi?R%2m#ypO<Q@c_!eeohr+BJl-$n%^@rJc zVJrtCu`dV*&tLa~{pqb>e+K0&?Y9Z-i?)H~Pa86@&HYs@Enk**Wmz8;Un@HUbREg- z1@g`)8lLw9tyAk@>Tz$-j&g3}R?-3alM`NG7VFx^t)v68d7=kcC;PQ=D@iaWF-&oT zIoY3qPO3`_w|WqasawzTfQ4rwKtIO=-3r|-&;7n`p(ki!T?3by%%?VMEYXl}}eR0u~8-*>a7egC@(77 z0ebnKpj+S})JAty@v{!0HV(4Wd!;iAU3(}SjHJgO!_=c!#v7LSv(=#;ee_JLNvT1y zx^k;{AC~8|mjp6EsR6ujDCRIgc?gIH4#gY;w46o7Xh8+u&ARAjs=MYV(Zd|>5l<)I zq!ydq8;WngK2|GjL#6ng2SIa3pUo2_YEbJuhcaZ!bJ|M+3DA@@K^wP{&U1`1Ji$Jn z0J+J8Lovr7-wPaycQhMdw>~yi0A+MG*48?Xw#eSAWmkVP<>noS@arM=%bUAyX2#;LLWhoZSwe7Dd3P#rU~6 zqIuD8I~kmb8|JQ~HVif#{YH1fk!(F*8$FmR9;Ul?nv-6Z`z>y~#uj9EWSuk(aOv(_ zC;72FM|Kh@4$2eKFze0?lxaBoWI4n7 zst!_O^F5Dg>)A*91N!HK_XgOEvq9IWqHJ6I-g`jDUdcqLQ*%Qw&++2TkjbScru)Lw ztRP-E6myJoykY(s9EfsBAmuqag`OgEwJ`@5SG{TRkuB*wP^|l7e+#rlT(7;8E-aa$zBqnCzNuow4YP46D)HB_>({al(7k>W(V`ap_pTmi-6FrbZPj2 z88Rh-TKHSlukBAMzM`m2y7tw3yq41@CcU9CjNT?5i1N{h&C`OkQeFP0?wq|hUnXc? zTqECW;WlOAY<92p@IexgCuZV676I|WAuBP?^S(d-?6zjTLNCzCaRc>Z&VQ?TTWv<& z=w;r4oUTv&Ut@YGXbkApYlt!}dK{r-q%vvrUWXX!HRzc*`{#wqP@y5u%w&sYz~Yxm zWac@OGI5lj6Cx81rX3=h&oL?Rg#|_1(N)*MhhNNzRZ<^HFYu1&rQEAO>G(9@NN+Fp z`CuUV_F$TGd)LWu(YS+4(mpNPE;7FuBzC=uKoNVag0Q4#2BgKdwz1Fjw1=bRbtuz;rX1c3LE7MhE zk>xL(o*OD8C}=S>MarOPAw;#K&R0K-m=)Q7nkG$G(2|v5z2ENr&a+@OeA^33Ix2lR zwf~Hn)lLp7ENta?tmUvR#BG(^XESLpd z4eagIqL$Z>+GQU%++~u_tHb-5aTYVIm$GtyB^4z~{+^5f5_*9Ky1hSQ7WFPIKcaxy z=iRrAK6D)Kq!YFv%y|FGsF^4IbEc;RmRV)`Uzwa6c*D9N_!fy(j^M_GIFBpi53en= z*uO5v;_H=B8h$gwROT5uQ5~GMP@RLxYL!Q_LG|Pfr5(4%amYp?ni6?hSP#J z>irZI7001yQKOYK-kbQA?r=*I`b@|0oFR%gg(@r|GR(Ze+I4{CV%JqY+yaG8^{29n`b6^ z_WB6RU>L6%PV`v?Yyhr)1fXv;uSS@v24KAo{9NFC&V@b@e1yP*2t)So^AW~tgvgp? z9?%vo><8=}WC0st(g@?cTbbo-K01Kjj+%QGmK|>;}9^kmZqDV3sv-=AO0utR)6^%Z_M~#UP3M`VS zV7Q~dr$LMX1yz5@HL4cWj)fq;j9w*zh>L1AZI=@-ZJ?-pDqd^mVt;*MHNF+JIQRfJCYs$Bof>@fiPm z>s8V%Z$<(Kqk=FB0kWD;FtK4Z4WIiCtHS+c{>IM`IwF_T+bn=M2!dO@fca0XXZK%TAboKQa1Z0AJw$2EERw}lh&W_=yXfgS z#o7I@5r1^Q4bBV}Jh_o0+c(hB^gF;oH8Ua@?ufNd3{|0M@b@9-#H)I|P0o$+5LxaVoV0B7W77$ zMSn1khYk9e^uY5})IH3xy*p@_yOjUDyb6)_yj{uUTddRuI0cQA*UsU>$%E83%w_kV zpTkHmRs$FkfMSDwjN@_U-L*)NCY~rDolY};+GD6@5HWco%1irU8&X;Sd0H+n6a@Gcj^m6!(4b;@l;eTAyekRRW3dBEbKW>c$1n?L?X%-!q&r?!f z&ACIbBPJHr1v4BiAQ(gvbf0?{BSPS3@CwVpnTT4OS6h~!0IEO{UO@^C`Vo}A_OqxB zP}_&9MTXFzhyk^zX0Wyo=%n|`1rh}*g1{%1stOxTxg~W#BG*nL#>E&2GCg3EOn;d2 zb-cn7dfLx&<@9#o${=xIP<4q|5z2~@$2mypS`zUE!XL>z*^ zlCch6!8jUM{*>Yg6X?3o%=pC(TsXXhqyK&mkxbO!g_>7Y%0BrGW-j{yZKvPIOQxu~ z|Eu_c<>Hy+WPFP_Zr)wJn;&?C)qf1dr4`Jst|L_xC*9e>>uXm4XCQGOpLzN>H0`P- zNFN!X$vCz1SF!hB>u5i+5lB+^*g9q}`7#CLDkv$dgI#~y#DAVYt|8sl z%(R7H1NFkq(AWwfpaD*DIt@#nVdthbw4HdBSa1nJP<*V-gnhnbgMBb12qJ>DK5Bi$ z2rk{ETeq?Or6;JIGKcX~R-hUTyB?U~<$r^sR5dQA?d<#Xw`@Zsp0go|X0{Ftnagwb z=4!*VDIRDuM|N)F-Z?9Q*ng1OY=mi4D6VXv?bKe7-0pF}KGy~}0Y31%1r2w#?PvDj z6_$Z1#oAyDEs$O8#_{`mb6n(lSBWKyh5>$HZ3$=K_eW%aH{i)*VgbcwdhwE}L0m+` z-)uJrwyeWXU$_C-!PN5M@RH*~%?I64AnVu&5RoEuwKS0`p9<=NIDdnJa9n4Iy9~nK zh>5bfrMPM)y;sfyonXc=h}9~BaQ?vGDgVSA+>&{qVOJRfa6)C766%+JA2(SssFq`1 z0&!x|?hBVr{&D#Zttp(=D;JV|NoY=17Fc!#4~e>k8Q5>%Cv z>SfIQ;)}GNK8PDHproRfqdQ-rrD+GY_fWJhKb~krw2Fum@tqcAGG+CT;uV(B)7DH! z(?5Z(>+1smg`}zT*i>$J5J1p#4~ zU6HrAJQS4rYPAy^-!I;eb$Kms1miPpnxu9z(fBqhs4PKV3X42eMfL^am?*ly8X6;V z=hyFCxI1@I!=f1d!=3rfz31$AzVkchp7VX*?j1Mo)#oMtM1SJI_*vzuAH7Hrhfxz( zLG?qzOOwe_@evL5X;n{jwv6D+jrUN2+P{{Ai$Vwr(0lt z522=Qb~=(tFV>MA*$M^CPe$zrPYgh4mRVZWul)2`UA~5FNZQgKNDb+n zM4!g_7}Q*(sffQ9Bg(v;lj$?;bYZ3fF3^}`rz4s4Vt*akk^S&F$q)JCeKQVThIGts zPP^l>Z{@GWToe_ip{7DU zyJ8*5q!;VRj_ileNq)#5-SZqMfR^;$>3f#{NzC=J0lfX`JCpqk>h6~@fVT_(I>n&? z{Pnp#bAOEapvOoP5rT|)SY+M_K^CyIeK-7Bjf!=V=Iu~0vSJ;{q!;VRj_ileNq)#5 z-4h2NV-^Bh)V)r5OaDA#0B)bInH**;>{;Bgn;dcx?eBrGsACsab$$pz7O=MSV=Qdn zVW)fN`UhCnvByqFGU>%SvLpN9bCMtONB6`bvVVh(Z7uGd;Ya}V1YXu2aC#NMs{p=- z0Hmym!&wtA>)4B^_8r{9j-4PZWlhL8?2s&Azoiz%U8Q!qcRT-$<=5Z0(~(SiL5^bB zeWwhcSgIeHll+iBejMWvb$urmweE1diGB4sJ(Mw-A%l|}GC`mvkI(jyEMR@-e^B$a z9)I)M5-I5BnTkNmuy`EFq!(mbf$ICrSjWf?pOgHMKe{Ilvy7SWZOMFoCA7P%4Da93 zu#(7tjOmuJwVkrK>BfH%cXK~B+}MZJogeXf`ksjGvsChSFGUZ8u-ZOXPT_QK z6FR=_LQj7mz8&ZX$8oqn@BrQYU-SB7J%3sh-ad_L_SDGkg$EF29^8x!_rK!FItm(3 zc4R-M0nkpYFckDqjfR=GaiQiS5F+^<_FoWWLb$mYAKj^7+3gz@c>!cc_B4-pUh+f! z=$<%)wHIOj)dI(w*o{4bAbnmBo!{vQOwCN7vy5=|Cj_#y%8stHwlaBIkUh;q^M46* zlOM&}fOMOO2E^!4Aj>2KV>3x9pi^ACWQ!M$wB6qD+--bTRD z7_2y#%Lnsa0rd5MgB4YU2rg6NW`EyZyYF^gFM(HU+OszCclQ8oSmyh;?Ms4XwY~RC z-0HJ9R6<|e%C2!n!C-o(sLb6J!TuW8IB>iDizYI1bEaX!t+|{*ajeJ zQDXYBRdD*`?>u}y+XUwW|G>1*R$|&gc0KeiNvPJByrNVw9y4H z(rHw7ITfzQ*TQ-KO86hz0@s79;BtuJ@M^dmS&i8zqTzLdkvg#suAi@m>#6mSmqx?y zYy#X$H^TkwCZ4>fCK@3XshC+F4UaEk;Bh_{o>g%Osm*}ACK;Z!?0+d-Oys1EO87Qs zjP37%NPRH^PA6c&aU}wd#lr7+Ec{Qz!T)4DB1%*UEm0z{G!cPkk`Qz*8R42VM3t)% ztWmP8s}RhHhn!Ex-)ah>s7{BXCIcZCG7)-Fjpf>6L^R|gtRV;U8nd~1O}SX8%^mw6 z^^z+p1ePSQ%&)@qBYzA>SMz1#eKH!7ldQB(u7%6#b$r=)or#8TSpsI9+X&aPO?=r< z<`t~oschUUW8hvD%a@I!E`x94nHSj;t54uOv`LWFCiXgIE*$&QXk=Rv+)Fmv$WzUG zpNipYULa33FFO|lUrnNoJk>mvje^y?nEcGzcqr;wnbsv^W`7;)18pMbaVZ&c7Qi2$ zM*w_ZTuH->vm0#<>wP*F(vmH94J#{SWu{3QBws$*upSk$2&qZ8BR}&(JVLZ;$m&zz zQJ)C+OG%t(V+!OKlAg~K5PsB4F01(LQrXaI<5jxFMxM$>u8D(Rd6JDhm5pa*970*c zipf*igfyrj*MBPE(ZI^6F`4shRziL;*|7kg<)68EZQIx;l!{1AIzlz62-Kt?uw03t zimeE*%s^Ci4(70$r)4s@HXR{D4Xa=oS7@^kaw!`jjoDbCWAo_>5UR^V_~kq<{AxZT zE{&pz0}R>SPy64f_tgxZy*)rqXGlMzv$ z!x}c3)qDzHHu44~BAWBz*TjB1H>X0TQ*qvx)8OAgfA0QeGDaV-zC zDn$*;%0^z10RJkbUBl8kA6B2mmkl*6)jX9=XmbuDuYzYY>jRyVlU&{k?*>)x)WXG6 zpBRAf(tp5d`{LjDUQ^%oGd|l3j(}D*=UNrRxo9?XGb*-lvdTm*utv$bR>yL#HF2C< zZ9GSt)Vzi)j+RPVZKz?X=EHS49A(~+*TTcu?^n&lF(z3V% zzHMrF88blV5W4!9ve}7r`=CyQsE!?&WmMbg!hhP?QQVvdIs&-Y#=(tc-J9*KffrJl zOwBr&afvpWZB#jY);${8&fAm%Pj*zHn)klK_8FFWA}`S?;dXfKVaDyXaSpsA8~;n! z-%as4y78D?tLpY{O0zQb`nRN87w8JDL$VEvbVXL*rgS^q{FZ!caEp3SzNE5BG;vnn zvVTPD9F5W{DT}j8nJle{x602aSm)PdTD__gt+Hx{nk1{NHrYB~n`QN>Q}xR0l~(zM zBBJW75!7499d%iitskcq3?v4HM@& zDJIQzR=mNkgo!*v_6wX96Qq-Ui3@RZ_&r@Q4V)4=G83}*-Yd~jq>@}^WhEj`@Vm{@ArH@-{<=`Jn?r-Zo!~jPyhe`GeROT|9$uW?Gos}27p@&0072JBZRKS z)8Csf0(~D){JL5W4`&K(hmk^VhoZgrrG>~`4k69Syo0zeEL4kq9EJkhU?69K+JdnV^;_ZCoc;554SWcTQmO zUP`}asglxm$n5#~^TV0hCK{{%ld!7Z^;d~i$#Q=EIdRjnLKLQW7Q!M$L5wQT58Qed zJZ*5>5)EV()aA#u-D$A7PLt7!$PmaE@%mHOi5d3e$3D=^EB+on-Rk+G zPK;yK8s?){xiwV?_hNjeHK;onU*^an54fB+e&<$$|E$zJlz9@EJjrObxT4nJc$5Wf z?>gGuMGgTt>6C~BrmJ8_;gKh6$iBC0rUh%2Oz&wx2n`uTy35V21@2!`dTb>;DEvsbI_hCGXM5{ z_+9;cyK{VQ_KL4~B|su0UPKqO3sryhRu3Va%!vYi!M3g72TuAkaypkOQpGw!Y(5aC z_2c98s*Z;Q=E;05b+%M;{r+n0%TU#wV=fFxFU_$n24>9ot-}O+v2jymOS+gTfe%J& zQ#f@_F}t`1HUY?h6qtrz27xUI6qPPZB zz>J}9G#bx~=k=P6;BGK&o8f?_*4h`&UR4Y^U_e8+hPSsT>hc-m8|8wXS|X3AwTKN2dE53nwy5svETkR0l%rP5Btp61(*m zflGQxKeFZ#r^j~-Fr}m zJ*0~;Rn5@(L=<)m(tm7f&k!FhC^rY%2xCum%;0+*ENTga4FVcVq)QO2H@s(k`f8=8 zfrmwem4A@uyj;yYw)Wuo2dzMHwTu*SMOdad0fDLsyy1Fs~{4)ZX8C=BJWM6Ug|*Phtb7x8zO)c zaf;#1G|k6TLB($6WC&s_^6v&}wzP-$L z2WNVwI~q~Ws9%K0qK5}{avGS@20}~HoPHcxCw}#OntR_Q6BKg~|5#XL zqOQO)8^|)4->w&>nevUenu_nYJL7-Mw(;~r1*_XaPM{^J3Q{oa(^3Xt9fTy2xLr5J zMk`i1)&NQ6Rd_bsIV#m%k$>+&2 zIGkuBpc!#jHM~!D!fVpa#o5*U7I(rN9)(G-k-b-@bC6&VPsi)X!u0{c2@Nmjj{0Ba zS8t$w+oD;EP9?FAvEiJ6?_HIL><8SfFW>ugF)lCuZ5xWcrbhY>YRz8irB^Nn#oweg zNOnpAI>bOLN)~~XHBb1ejOa{7xN;1jjzV^@G|$>N^S|Jz>@#|KgD%`NYxLT87ayEq zgv=ce|6ENTXAtrdw>`}}d)RwsWkOx~`H~5t`Y8}iq%-oxA ziGkSI#IN|!q4b!wE*kbr{&l7LZ6T;&$MUeISb|+Kw6qMRS0)YyDJlNM+nu$ysU@qR zaX7Sj*J_cna*gE9xdUsl^;Rz-SwcSgkrTsS%}!EK##Fy?Hb3zNz3u$O98Bhrtc z@@A&eeXH#0vKwuYU?mob_fsN{w`B@;io_tW)!KvXrOm&l%nFwe>*@ohDHGbw)L)sy z7r<^XR+_+6X3UDNKg_2_8qc2gwSZ}y1pEvrNiCEu1MT4DmSvHFy1CQddm zmuGX-hX9B}W)7CSV{d*T9b$hd{H}yu|Ky3kOI7Qc#Vop+0Z$sC}DE3d54G)Vv|7%HMGb9`3 zRq6IO$_5tqtSNIV#O7zP;C-8M=k%G1d^UfEqEAPEWZY(U5XeB<*P*<*~t@R_7V)e=){?wQJq9ZJIcvU_@ zIhHh|tAxHM$+8mZt9simJ6Z^SBlko#e<4$JfnYk9rQoT!UNY8TyMD@=E8vE- ziCpnzbfU{5Z=8k0#45x6w=gDgSsl4gzP$c8P0DF}XY+;lAVC1ZZpNYxf68!a|u&4z9O&VGguza>t7=AQk~&h`I)(6(&5MeSu=5A z+2f;p*_=pb^<322(PK(S@TWk`_omxUbs2ZBUIQd?ej3@R+Iq{nb)a|F|0qTR)(+lB z{dW0#qivdsvb8{knE6On%CKL4WBFrWed@OTSo<^OwS7kr)cy_GLoJ{{1Xc2As-U>n zO4A-GA=mO2w6+VbSpPX4D9@_WUhZ-D$% zp&&##ZKbQap|smUMX%`@7-?f2#=g`1KkqG9Db`9kC`GI!J6z8b z5%4Z^^|+fda8ChitQr3_I}VD$WW48~D-o-$lWw#J%#}zTCC*tSqON`G`L2Y~RjsyN zH04o${_~}FQ!FB)=Fpk|zMYmu_0YU|vT$~K0kUSv=hcG)HKTrifry)Zaa>XOrJQkD zqrq|F7QbbaeJ=-JB!3tZsLagxs!ka8@^mhBfae|M`qNyblj^Tr80uZ3P(F;Hv)O5Q7EjrKd){iXOXpknL5wE`E*bDOh zUgrJTZt0@AJ}IJ1UC0UVmEe8X$dSS}_Y9xG&q4I41l8t-^pai_HzP~9b&koo*>jf; zr)_G^qvrro_vlCLIaxWLfuSMiLm@I<{w03bUnNT<>`P5NQklxy(D~IWsZaaBW)?qv z(5(%(-TL~KF|3HOv3J4h;sgbo%3-mA5Ape#d7+^b=?W`Bd)EW+j(#2+^9L!Tdw%wl zKjTJw(7oY?{DzK-+!7r)HIwU!NSD~p3XOAITiiE#^0OhBB5`r^hQ8DUnlu3S zT+F+gf$Fy@4A+r)&&vKym@JU*3jBsQdkkv*@uyLx=@_-R9MQPpRDjr(l-xuN6Xb4i zUi;|sFlW5XeAh+=DOy(6-x_%>sb6;a-DEtQtr8sf6W2aoFp8 fWB-3J9(MsWfCRFG>XUN+SE)t@CWtCM`-uMl+5s*# literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..108f8d4193ed4cc2bdbb0a009da2262afa29622d GIT binary patch literal 2401 zcmb7``9IT-1INcU$266p6PY6w!bnl`a34_!lN@tvu7=T!Y>rPu-<&N+<)a#7u4Z$` z`;%inj`VR~v5&J4<_gilw?4mpfBgRNdc7Wx=i~JUJdec397z#KXk5w1%q10d2I3~OR zxSq~Q1&9A4?UXCO$PDl=!DD}spQm5s=LsC13+>nKUNczU)U$M#8U1{yv~-3J4!~m< zW$hipz|I0?1Kvti$8#!2Zi4y*4^RG=$mZQOSgWB_Uwx@s2{KoF9as+FjN-fwx6^NxI6 zcwg|eZIS`>yV!BC^!XJraxV}_9ts{OI_tOZ>lrikHOR~4%6zOsLrpP-M0pFSRJs1G zj_FP@S9w-EBSa8eax#RM744>0xIHx4Km%3kS_LyU*97)o|IwiXTp*iD zjmrqF3ZX@sjMZ>i`|bik!cGDYuUW0!@JinZOUlTdmFG#`OK@?>rirSv6hBcZTrHk4 zj%WdE937gRb0+*B%TX>5bBWOrfrILZ4KQ?*-@kxls7eIZGE_cvKSKmjF?GOZMj z79RdXN*&TH64Rlg+IvG%xnIA+u(h>x-5VB^5kFnJT7CF_@ngJc^22kIs|ic$hlfK{ z5gB?ixy`E*8CgjCPoDH68MI~tWu$IuXFHM=kXdsI*?$okl~d!gdC)&N@)5~Wt*<;` zup|H$SFXPx{yj+Y#9pzgiz zBRtb9ouw%*C%z*-A;J&m!`rsXl>Kzm#Z}& zaa*Q&%Hd+Sax2fNSP8|E3j!p*xWe}}MXva%cE7vh-=Qg7bRwCfbbTk+ETKiWLQB{D zlIdllrT$I9I14=+zu{S@A^CL%qRKb8Bggn;H1|=g=i?A=cF$|h%+ZOfrp~vjZD*DR zlNQFJU!_BX(Wex%g_?RHP@8wi`3ERD=FE9h?w?{5okWR*06doxm&4GOS(F|rbS_p0 zPMTTX_WChFmZNR0(wbV&J7GGQiZH~uAgX>)$v1VSbv0Dh5Pcx&NKN6%#nAA z*9b86*$7Ygk)=zKxztJxKAbVXU3-GLaJdTgBm-l8&R4nnufb2j{z=Jumy9@$o<(BB zes{a5P={kr>t6dD%JNx1(7z{KsIv7jkx(9RZTZ5Zi%hn6TjYVzWQq6R^F?p9t=Qhq zA@AuU!>txc+ep*6D3 zmB##5zAVTa!2R>azu=6|T+3~VcAaffadG-hYqgb#&1T;U64loI%LrtgL}o^7e(HRb zKlZw-YbYLF`PU@&B~LRPU9_%(cXOm$j0fg^w!+KN{5b7$A7Kt%jJKHcgvgta7TBd8Rv?n&STD#5QlL+yP zv}=0&jZAgjoj(=V6$Os#nokOUH=3>`(-VDve}PZ7tM9&oirW?Ov^kH;m!4A%X}X{X z%9gfS6845wbnI0d(Q=2U30GyMz!p_i-D|R(Bno8*Nr5@@zrXOn%l=a}V6Xg! z>X&3gG(GmAYrNpo_MihQxrY|PU|LMqT8CDCjguTW4#wo{VGdbEi;p_@2V$a}y)2o} z&2;oN3%f{7@iUEXy4BD9Up1_W{1|f=lzwurKl`a6My+p|8ZM`ok zvwU~3b+noe=V5w4FvDmVfrDH`^^L0}^goRm#uSFwW-4$PSL<8U5{D?)tzE1{PK}yh z?`rh;0L+~Bms{t*zU+tclp^|Prauo{tqo1decZ$LJ!bqZi(b?V@PUcm*m>Y0MXt{$ z*xUn-TMCtz#JS_N4qXr>rK_dWalSV1j2)y2xlp2gLXyBc+Gwkvt4nDdHzF_M;i4xm ze6bGA8V0=5vNg+cQqf;S~R>$p4DR-LnVaukT?!9MO#apXvm%G`(R`VT6tU EFEllc7XSbN literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png index db5080a752731b34d38d7c634732b2cd999125d0..4d1c0d9df4db14918d74a24c72d76129d6ef3fdb 100644 GIT binary patch delta 3068 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 KMNUMnLSTYZ3G`9` delta 3386 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 R@%{h+002ovPDHLkV1hu%xqtuw literal 4926 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 XV|JYF`RVVU0EwE4rgEtg0`>m@-kHM( literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png index da31a871c8dc461a1ea114510bfac640307b0a90..c4e0a2f19b464b7171ddebfad114dcc72030e75a 100644 GIT binary patch literal 8167 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 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..57bc7641646dd09fe3e878357df24d447f3ca6a9 GIT binary patch literal 22443 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..9814b7170ed67848b8e0b1cad979f698d329d328 GIT binary patch literal 10599 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 P00000NkvXXu0mjfe_)AI literal 11873 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

hLz6hu@J0vP#R$Is&6%qZ0^&v)!xsyvM^+fV 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 HyomTWcJwFX literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..2b749b2820c9d2f58f1d1b0b43fd59fddc32c6ff GIT binary patch literal 15843 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 diff --git a/composeApp/src/androidMain/res/values/colors.xml b/composeApp/src/androidMain/res/values/colors.xml new file mode 100644 index 00000000..19670936 --- /dev/null +++ b/composeApp/src/androidMain/res/values/colors.xml @@ -0,0 +1,21 @@ + + + + + #000000 + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 66531e38..e4d68e93 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -907,6 +907,7 @@ Verified GitHub stars GitHub repository URL + github.com No releases found diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt index 0c969bfe..d5542e1f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -40,6 +40,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -134,6 +136,7 @@ import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_discover_badge_official import spotube.composeapp.generated.resources.plugin_discover_badge_verified import spotube.composeapp.generated.resources.plugin_discover_repository_content_description +import spotube.composeapp.generated.resources.plugin_discover_repository_host import spotube.composeapp.generated.resources.plugin_discover_stars_content_description import spotube.composeapp.generated.resources.plugin_info_abilities import spotube.composeapp.generated.resources.plugin_info_api_version @@ -848,6 +851,7 @@ private fun SupportDialog( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun DiscoverRepoCard( repo: GitHubRepo, @@ -952,9 +956,10 @@ private fun DiscoverRepoCard( overflow = TextOverflow.Ellipsis ) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically ) { Text( repo.owner.login, @@ -997,9 +1002,10 @@ private fun DiscoverRepoCard( tint = AppTheme.colors.onSurfaceVariant ) Text( - "github.com", + stringResource(Res.string.plugin_discover_repository_host), style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant + color = AppTheme.colors.onSurfaceVariant, + singleLine = true ) } } diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index 4a3148cd..e754c8d3 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -32,6 +32,9 @@ import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState +import org.jetbrains.compose.resources.painterResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.spotube_logo import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication @@ -200,6 +203,7 @@ fun main() { } }, title = "Spotube", + icon = painterResource(Res.drawable.spotube_logo), visible = isWindowVisible ) { CompositionLocalProvider( diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0c944468c11f80c326dd9bd952352277ff62ff8c GIT binary patch literal 4072 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..aeac45591bbab6ea0215fe116706e70c83c48b32 GIT binary patch literal 5025 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b293b0c7a9ff3251ed68fd58a29e1515e23eaf78 GIT binary patch literal 2237 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..092f0ad8e3cc2a779772276fed8f2985fb737b4a GIT binary patch literal 2801 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

YV3-z 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..9214b754bc1c251fc03f010ae2116626e26a020c GIT binary patch literal 7449 zcmd5>_cI(&*IumNh1L76UV`XB^e$MP#VQe27d;U+O0)#g#Ue_G#fn&6bk+tbSfWNt z^pYTIzT}(l4|w0-z8{`@XP%kooHKLJ+;i_a$>wJIv{W2a004m2&_KuX?>_Sn6l8zb zWIoAV0Dx=RP)E}$;_EKV`vq(&6n5HOc5z&4gYY$^05g=|!2;YlZwxcIy~JHFoA72y z*NpzbJr5M)g|YjtfXyth+B9tB3|al9eD`k|tI-E^3iFPu@749UeTkN&Jg>Jte$!FX zez6dvvc2cM+a>5uLxF5ndwK?908;8PfY~(vA;T4@iDdj={8!}vnfNFFKNElD{~`I$ z;eX}qU#u;Zq1~h)UowCwxrdyFH>2nsfuEn-@tiA1ENvTPM^qS1ekVEmT1~Er z+`T^;&nK@d@TbjAE$`%Jih)CYptfttaeBx{Wp&oHcc6^5H55~Tn^mZg3PcnbU6T|h{sPb#1!&ho zq0ZZdCqhY54kjvkM_7ZVHkfj=@A0n`SdFN*$7+v(p-j3 zxQ(|H&keVPdxHYIq=EFYHvrGXEt`W!WKE(p;^P>$Wn-TM9*XcbP~mck(Bk5)qVd~r zJSa7h3)Z#rM6Q+;^5Q@h;$?6wR14k3!}x*0L)2~XC}t@oV+RKKBR;)b_1&gE+QzTv zk3Z9n*$cdVondlWSn5SMbB{N`h3KF(>r{7Q0!fR#AyFu z(0F09q3l0hZKcF?OmYp1_<$BbUu!p-%{Lk5(9lUp&E{Cs1~gF5Za`u>_(6U!9`5#3 zli>N}e1r)^`Xp66f}^K}=%GEjP<6y&R8x^CG*R0YBBrqJ8t`;5i0y^aT`$2R(vkSs zA`X%WgZY^k0B52=Jx^&E)nh(_UWRMSu^K1TFfAI;30S@W(6NAX{{*CB*t-!-)C<`I z{e?#5+sHs($I#~73^CBXEs>P^Q%f|Fiw6;I9KGCIItgYH?j!hB+c*f7z zr?nMP(i;DmKUYFj6HN->O+}o@`053ivOKwQe4+Io&p5==$hq~kD&hVqajT0zF*FWA z$taomemh!hwsDW;HSMYl6HSX@@h7WBf*K&kB~yHy--cJgJ)Ev506cAP)o99W9(0R1 z;ofVF9no2+KhMW0=I9Uy|in2nr)Zq+5$Q8 zi8>_6%t(~s8Xc0kCR+e66YBIO@p0(`TT1f7X&K=yw-q7NZ$ajtJM8>mE6uuwMiL_g z{HP_0g|JTvxJ$M9iUx5HEYxw=p9&vb%>8@@NQw2^&-p@cM#V^n`))xd8bBOD(xVz| z&YEBs8Z@}E#>Yz%XeXg>%oBv8adRBO^4aV>;-7x4wZ#I+8;DYeJYQhnyf#eU7D{k3 zjeL+3n{Sf_oC6We37u7tWQ@kD+AWMvatV==G-X<-a;ZKmEKW03cOmX7;j>`=Ec=U$AV7CX?Et9Z7(T7N+fNg6w~?AbS{-{1kyh)f#~!@dM7YEUlgMoNjVKVt4N*81G`(qX3h%`-HKI>~at zno1hO+S}IJBspjNf(Qy~bV)mfsH$W8CpX`pX!UC20UnSawTa$A3X(p}xrb{<{hqEI z3GKo+_vt9x%-Fu-62H%$etET&iJ1LWsC2b=9|;t%oC)+k>k1ZmPqG|`TpJA`F}-8j zNKv$T0mPbx**i7S2H~Cj&{70>YDX(|Fz9FL`Qvo3bK&^Y(^p8aDxYF5WNLZV0_g5Y zO{tK5T$ru>klBXmIEQI%^u~PqW5ZbYS+{G zHmz=B2A4VCJaRG4QXx9Umz1%Ie%@?Lp^faK2V4SrN@`mX+3(B9mLnVABHoaEeU77G zqVcxZ-sRwVF|*o3K@N4Siv;|U%tbh(@hQQis*6_Dns;Ov1@eVZ&|;dkzp3?3nm9u5 zdLeCDYB0-n6@ULB`W-qPHP^E39KL)6%j18CVpRHxoR=sSjL6V0?0lP-$ z#9BwV0&PDvzmuqqK$l&En9_dNaru}yy;ci8K=H^F1d;UMS2&!libpxOZid~i^$Z?z z;Ed1VY&?&-YO-+k22Oq~;)xyB$9M=2CUi?xz;aGAUsV6CV*}3hSBs%y6Ldib@7B*{ ze5tOp2E=N`jAst5Hc1*#R@nPR5qN$RBls-veAF!{2|sAm+sv|iYu(o0VvViKd;651y*HTS?k8R*pgyWHWUN9s z@tudT>zw<>U{>Ae{&uFTjwxl-0`npibKC$Qsy>EC_bl4T5K@eYA9VHkPB^Y%U8WHXB#m`zNTtaM|ln+^w0Hd-88IPIofmggs! zvRsA-A`-_42Y#pglkw@<5lSA1PA7*NL=P3qjrk^0xO3w5jRy;Ya@=K@eDP`zfYE>! zv1NS5wmU#0$rf!3>C2Okt zLmO!}`q@jnT=VwyN>Ie3a)|=LSt?)7vB+$f*SCZleT_}Q^_i|y>Af#?e8{k5HlXit z=oj%t8-sGX2V>Od+Qwr6tZl#8{I8mwJ9(n#pwojX#hiVG`*C9;&%WrQ{R--WH8ye33S^1Z%{v z$!eknDOvN9)2_r(6xWI!Wz5*qg&vDoD=Fiz2TuJNy0tYdrg|%Q{0I+*+>pWeR7FIs% z1`#*1+EJisn`e4zD1U2eFux-npwht_U7PC!^948(wnry^>ln+9=Y(mmW<92-#A;KJ zNwddhcRcv96czk?!_}*s_r-Ge=A-wAEA{d-8~7$n%|WdjNEo8GD zX~z;ddwK-1^Um4k>|hI&Oa-gC1ti88Hy6K*W!Y1Av@9c@d4@D?WAq&zE3dwAqE6ec zO0{bQ>T~5o@{g~s;Who^@AA1`u~9ePoJh7lBSK$G9}2A(av6WS``d5ycqhp(_R@H! z#*XcX!x7_tqg{E<@$GG(238JiC(p#ie_Bn^drR|3p|UiV;UG;h=L$=MX1cfw$}Y<)_f zeQqCty&Z*J0CZ=|Y|ME$x!6-frA4Klu|2WAyW=>w;b6*(Ek6_UEeqo#|HI&mmDyF( z0EjE7vt}qklpQ6T*YJKwoXRibEGzqXN!oV!0On+uB(so*+`S~9DK|c z{(ZdUiD_2{i-xK4@7M6AXD^6ewVF!R=du)PlnDQ*t7Xyi_MZ!IjV7ok)D0^q6QpJ= z`>6%ZE~mbZwk|LI{kq*GeZ-YkCE>LX`1Em%9bUCfe$p%Qzi34#4tCXSO;=S5<9hG^SB9Y@p3cgGUvSAu4T zutZ5Nm*HvOJ1seHYv_)mG1{hIa-}C_u~qrRht8lyXcP6q2L`X+`R}|Vt2K;^-}^EB zA@va|Bl#e-N(cjN>^>_x|G11eE@GPdk$t+iBUDbAsq&=@G`-*psayZMZJZ#qy1DB0 zN}s&%?un5VrzuQgcb#cYS-ar^uv1u(>LzNN^V+kbsk|=-DgTb{CR3*7PGJr*9sT2I^{T6?c0=xD zk^qz6DlL&oZ=qMMU3DAUR<;e}u%Qd%!j|~%D0ui9VkR09t&S^cUzC$%v(o0Cy9zH! zd2%f}mo^47uodMpo8M**B|m=P>~VSBy`^eA*(?+{W$4Wbd#P~93lA@@q|90fdEPVH zdtw~?+P;~Q_&5bu&edB5_xi0?8^-?HzM%!46f};X2$iLaV)}T4xMX)IQe_0pIOBANs70mN`HJvM;9blmD0&R z&lXy3>+Dt4Ys z)ga1TmmVLl2kUAM$c2%kdx;5ZJEoWEKLqwZh+3N3S@?0NGq47YIa)eb z2`b^9J~p8|%egMt{ZS_?sv`rr?jA7CYOQ26#t3|0&t7W2OJP&v{XU3PwV;@9TI08W z%Tc5+Wr(VCoe0hwO6f^$%10(gYC~J*+7deJ7UJMmC31LU{}c2{kQ8oA6ew(5`@&UK z5f}HeGSGN5stpZaVHCvKQ+mi+{tS29bav_^kqZfcAr#Uh+;4SYVc4q#_SMlc1!C_*X~hn_7}dK&KLRx9Jx4ydp|tx(8DtW9qUST zDM@222(prT604wd_Zfkyfv@q_hylTQpaDj@nlwc1?g9;bOTxFSYLB}DUYECj-gb;; zC8-m|V_vQJm)d~*zC>r}U3TV6Ag=Wnic;Sz_9K2ok-!J4Mt67}VEKEGbz6Aho<8fL~c&R%v8i8&L28C5APln#`~2Nj71dRADHDj z$i5cRXLv~n+NG`*hRdUpEMum}1UEnF&YuAt&2WiRYX`jXesv!u_dHp3b%+XbIq#ge z8j&gaEmHEs`lH9*4H(WMqp~ffC|IgZlgCQknZn12BWN~%GUvg!(-w&Py#EDsla;&~ zKAd-KNw+G930XOPevw036Eqddh zVqGm#WXpg2CK8V1uywpuLir1+IW?qiv8$nW)Aar7O>^J{bI%`7uD&E!@)a&7f|pa9 zjwgA{5M-qSey=qtW!NGPZ1Ty1w5+V!->_Yf@CAVSAi^op7bdF2C{O;}&1<@ktJlA0 zJoB-Tu03!69-+Oa>gbh6k#2UQ#SK9p;&Oh{Uc+8_ZF{*pTR!&jMSe$aqgfBCc{-j? z9ej!h&J_f98P{Uu!0Ld5`=hri3;~ZN!nYJ%b54Q`kyFI@>mvniJ&43z1C+IbHYl>-LUo(T%v%-b`PzFqUnenc1f> zW!Fe?7$WWgww`l}YUj2Y=LUO9|Is~P4^j13+1u0$K#t(m0+Ityz|x1=J|-e15>>}w zi^Wr^>Z)P0aUm6IET99wo!$DxaJ0P?6ub0;PvT%04f7-*phmP$kdPc?cWoD)vM!qCDdr6trG0-oWbJj|CwY}w_!5Q=I z?T3dnT@M6AZ_e6IY!TdGEo_2grhaSL^iB@f!+;n8#YQ8C2C@D7d$o+?@o_s|T@ z*sQuS=o5pVv^Ueu*BN%1B)ieV{y(drRllq)hZX?$AVVtZ1BnxMw~@Tb>+JwlPai{n zr9@z@5blA{t~KR{KNQ%Hw=FOeqmsoJRa|>=!LF)4`agL9!t2pK?1#qhJ#0f>8BBwi zy3iiB7i(WP;CVNl68!Q$$?+DgfYgD@bHQ~&-}e&;gAZ*a2%IY*6@0UEWjn!ZuX`vz zC1j_Cx~JGRlsC2I?u26PwTt-&~lRX2ft=lP;epP)A zl$#FaYT{2?&SH5ylv|EMDH8(A^ndcT28%>kuc)@ELQQO?sqv;yFV#1U3KYw z=2X!|V(LD|RsN(s)J(4jA>S$MNg|B1I0B+Xcj2Usda#C~pN-T#;Se72xz5ep?JfbE zi>lAeD#4+_AO|as4+0?D!RN`4kL<$>7>>a&0G?c6Un}9(=7gG4j@<^@EEHxd^&nuoQ>#osqe6GRofYMv(|{0gel1=;P1UK?f5~<2An$GE>}wrJmaKe|M#8RB_hj-Xb%&3+>3bs*gaM}G?k%<2 z@^A8ziG^~8Kl_yvIMf2;{?U8lTImN-AfzI*A0vKIfh8 zs;+v=@AtdE-}kp(`1{Wr`Xk&TPFPeI|J&xCjmJ6b*PWA{5X;G$U}`OhYAA> zp(GF_+~;_p4VVBl0&ax30Wbpe0DZu|1Y*1TeEY-Lw*&)(MRc1Gt-x`>X#!3LjscDW zjsV7K;+Ib#NB=zn^aHN~-4ZqctAQ23dSD>Jcww!M{}&7}4{b~O3Jy(y|FZOTd3Q&=nz^Z;0(ih&tBx>Ck{Yh;A=KOgWU3`_V2 z;KzDJ@3#Zz9-z2QDBw#1z5(R)YnK#)M-RvH&>W!%?brw4ipuBQ=y%LveOHLe|KqMt z?Ly$_XKDbh(5HRkK==#d01^Lo0iV`-(-Hc-Y3fA8MBaoL&6x6ML_G0gC0SOBrtRKJ zAe5Dr5VtyHx@`!h;{WXuK4XPb)tVG!ip~D!$+y5uRAen6=e5usy52OMbV^&AreqelGz<05TUyH6Eb=Yb}Sn5Tn z*XAgz5~&Vk<+VAP6q>j~5jU_7U+VWY;mx%TW1JL29fP`z5&kGpK&=E%YXEt$8DNNi zn@|R+r1gFxiV+!7@9L%F3drfhbHE9~37|2ACI`mlU|ch_H$qDzG`NcBg%S*nz(4_d zM_`}`LsG{zJVlja2qnV|VKGPFpkYLsYKy>PPeMu4;57-ijRhTvF+$Co1pLUx_aXz` zI1%Ezmel8j;$Wi-c?WVOXqC{}2FG{83DaT56qwi!jd`u-q*6ELB8Gq?1$eCowsynX zt?+U;>>7dr7YYs(LdA@tRFY#eBC4Wff?AT*<|fbx>1K_N>SBVZQ}P4gbOBGJ5P^sq zT#@p(V-ep-eNHIpZgQcq2%UL2`#4x|8qAueDDmoR(6tM8?uOkxux}8Ci{J-}7@iAF z4KSevCU?NJ$uR9G=$NQh$4f86l4Y=RJG>@f#D(DihV`l_Nky?Btf()^=pzBfnzxD) z&HH^1TRqLdwn<&aLaMIJn&kJbwpe2M(2QHomm!1c0O|W)7EL{yR zb-^nGFchj7L8zCK5Ea!SwOw!|xFO^vjPqb>JItL0^G|`PQ=zLH9$E~~Y=k`?^oKAQ zK*2Dt`8FhoIDvWUI26F!kTq(@58XVANGdO z7r=>%ZMBwIgDP?`X+y5YXblcnLSmg-=}v z&o71FJOS(bq2Gg|#(F`h&*4;@GMfrA@+4+ONc}&d4SGI=2|mo50@p5ri4)+Jjk#3ozoT&qWQdQE|klMbo7BY~V*IIodB6>Szw$s*@D4ct0+FlN4?+ zhPPS3;mt~CZ`lroCJhxsrE)w0!iNSQrnbV#$H45jLFbV$p$!@v6oNuaYRQMbL3m{k zyu1~bz6=`&VOaYIp@c#Jo(FpbY}^FXI-%Wzy#g8>Wz+*>+_V5Vmh_8+M6~rkN2}+V zQqk2lSb({m zaM9T?_c(QoSu($Dw1>7RlD%ft(vMvUga*fXmrGlzL4OxM# zIB(Yj8Xfh(NBVHxHHtAOodWOK1`jV+Yj%1ET={O8a~y2n1`j+8t2aYeKlB93^$fJ0 za}0q($Vp{ej%VNV<3P`k4> z@%3++5egR-oDiT&Xg*IRUR0M6HlF!(;3c8L%c;6w#01uyh}vm#V0;dyl;GAY;Iz}= z``?D6XTc>4VdXL=-4hciR9AKLz z&@~xh*OLBsMUhXY#M|Dk4l(3~lKhE9_{clq!gs>gz5_1|!1kR`*bbL{7{<52&+mhK z7Q>5!u-8?fdN@=^!FaJ6x7$Za+YlcRI{(&Rf~6}U*aw$f0!K}OyS@dhdSJ&8biWMO zT?-?x!}@OUJ;mTr_+KQ0dJ+FtAe2TOM%5s>wKBkPstveXsHK!B{i8z4NwCv}@gZD( zEPU*G_}&j;=}vGwnAQTEt(Kb^BN#H9&aZh6TKWTBEDhB8PTZ2YChxzc&S3%x`JAMhn zO)#MZw|*3c2H~qegslSh1u&o`zGzE(R0qpS=ou~Pi77}czEkjE%_Rk%UX24R=8* z#`ct;I|twWIW*^xKv9Mr1eD5CEP^<*TiqXJWL=DIo137AC zSj;eqnUY2}g8}sVu+fJ*?uGgD;H;z7P5)&b?A!%cT>#^K6(0AjytJ?kp~$G>pjr*J zXquA603*~FrA6}+9Wgt}qK$7pgn6AXYdSo-1O`3maNwFn@c5JP><$%v7_uZkh$FtN zj?KiS3!_XaLr1O3%t{yxV96MuvsFR6Q)fXw2T!erk(`2=fvlGJdgJG5rHx53oItAJ z+ei+UtX8ScId4;pS+xP$#=)#3!41^2#2Mf;hD4slNd0M1y2nZQoUDh+D!6h`lG7wr z6!X>HDy!8X;q;SW?FQJ|ry*usj3xT8N$5jn2u6y*G{v8RfNj07c^k|-Q96whKeROB-R@R2bI)y#?rg1!hiFN;_@^)M*FI z$B&QDx~j&>VAQGhmt>u4p#1O>+Vare3cFrcs^*9ZkZ*u3uc-QPWQAEKV3%_$B`P+- z$cRCaKzvVBa?ti+6E*Z;>n><*hK^R{fp@V98qO z4wchn=ZTX-A>}GNllXC9vVe0>g4r|F^^xixhl{X!J*?`2ft+T3paeshQWx6;uCq@e zfkb&tW8zi-7V%}At;>o43aggeUKJQspaUzO1BZoh<7IH>JeBnt7^#tYoSTQ)Nu~OWskLu{q7~`RifwB1;0Y6DU;y51+a2C-17&Ot`tI5 zU$`QnMz6ISk0 zg;&1Tt)5U-51+dMZn+k2xf4d5N;V9O_)&!9YMnnx!oOUtJlWg60Xs(2Dl$l;*@3x} z;h#PM?;C)pmMFdC2HN{|VhAs)Ym>4hU&bpCFD)nR)LJx@<_|cLZfDPjNg*834oA&| zTmKQJ9|J%65j?vSh8vaI@%3$b4h)8H_x*6&XW`W8(6!k@E(!TrVbF~abEm`1PWaDn z!oZ$&HdgDuFmzfW~IH;0k!|Y52wcu(JoIx5Ldx>*RN!QjH~9eeqJL zYQLqWTAW&P(w6@a8blRVLfpo<5-OEg0tzGW_)^%~4;@dyIj6yemqG7d@QPX`8J|Rs z?&7S|7KCXG@Gar0t5wFKn)vM{c=y=~S=@aG{CNuul5RzOKTr}>3@QY&WA=$@Kbg`WmVgpu zBm!z*M~KRM2Kp8P81Qw&Lk=GA2LB%T>?dH;PWa^-72*Di$LJP3d4g1$VAl=Nr9>avg628d`lT8yokEqWefP)%cp zv}!<&2VMZel5)}(Z-wcP!VL>y`DPd_DtqEY1VU!ZsMKF6Xc~_~J>`|ChaJ`3D(UJ7 zXmbe`Er6aLczBskxRkWygjN%k99?xBCZrn2M0l&GIGVPM>npayAVsNGLw^JO?^5L< zUp`+okmQ_ds3_|zH}BUpE`K!|j@}3Cj!8si8e~(7GPy}5GoF49b_EJvmLi!hVq)iI zrpW5tZ@nu(kUY^zX8ccDxq@P-bpDPaJhcoKoC-%a=Hzrg7{FFj2ZL z>YO-OHpBrOeK@o>!J2JKXPSVTUEMHB)yQ<%R%@~*8lH$_Mq1m7wC*uLF`?kX3!7B& z@7oSj5Z6hH)nwmRmlbuENg1(qadeOwToyugq)h2h0k>EBHN0eWfs?d~W~7YoKC!zN z#Tc{T^?b%I>@o)0bX4x{Qvjl~Lo=X$n;+aif@)q>Q1-U zX;fDW(q}wm#c4zqV?<0Y*}9LxA`}bI-ds^Zh%wMdRmWv!#Fp!Gy7f*zl~!H7A69~9 zHi3x!|l4VBRMJ6cU%+WToVrSwQZR$iFQU5epSv?SNMN&pAx=gxcOgZ%^ z-7KnaIEv>*+A0#sJ-9~wltPs(#~B@p&Z0yT#EnBhNzBR`!z*R>*c&VR)fK1SqYQ9S zRlG~%+~!da6!EV^!1pT>B@R8Gk;(Mc8x!7exhBDBQAXH z5qN4V2(Nm$Mj8r{QK^oU&B+*#Sws>$XJq>Zb|LOU|$284(tlT3T7 zP;E~~O#sJ*9j~jInn8+fiP37VZruJuoK}frfTQ)*GjTI4Mj;qk33i1pY6`Xf8$dbxdWtjm%d=-Vv@LJ<$Sv0uN9KgKeRa4-$ zy$Y7b%^Q;G>gO3vQzwf7_Ivjv#hl`drm5k7wFFPEfb-6R8I7tRIByN-a74NTV~4dH zb$ZZLDQBs#Do0FI$9rOnyhO}sU#pw55qMh*yyHxGb_Ki^RQC)}8xsWZ(oOY z_rb@nSB0*>{xkG?%9wd>Ox>o2jCO-x)PUPCR3_LXyD_5*+slLrF8aN^tJGysg7GD| z`T|&ZG5p|O_~TYJ_IEg_#I+JV`pT^u<9jCwEvi`za8doOKhaa6d>OBb%V!Ry>g65u zRrlk)%VA&`K6*8rG8-Ox64q~nmV!>cn~`LC1=&W~=;OEq3n?LukV?6G&Qs2uG5C(O zV5WYZbCguI6k!^0+%a(Z1u$tMeDiL2WFzc$Rl94^8sw2C{jxIj^~aiqiQ@{q8glYq zk?>)m>QrT1&8V;pvCw%~;j1E-U#*1=--K%}fKPr92Kr&32U^BKv!0L@NCh)P+YoAX z<=XW?5q)pphw(%FP}TT6y#-$Rotl*8gq3tDR{uhSs|;;Z8@zuJG);u2W>~Qr{`)8J z!XWfGiufb4x|%yp`t>CSfUjibY`hE)&a8s317Ebpv5X2{+53o;m%Gj^JvRVr?}rng zgas$U3DY3g0CQ);bK4=%-DpPdc>>%b%sdh%Oi;zVp=U)^WjyjQYS3|D?LOGBN0r38 zIaPva#7F4VY2%<80OB~xfn2vn^@*+RQR;FKRZU@c@5pT3Z>0b0s5ud-!#dm=TWppA z*9e$sQkAKtdZIeH;iK9IBwFdM^ zGn@EHJ;MJ!-s3I?xL0i1ah%$^3#4VCZh8-$gc;N?CT z@D#!+L}pOLw#g>1zVa|v4)Dk_o4zf2Xy36|A@W)$r??SMv%ygY#kK11O-pH@x7H|z z)#D1ngcPNS0+LXi?$Qfv->*K(q)(GQ+GQK|47g(WJ`T6?(-L%afWm+CVRF9h(HHJpUPc5u< z{(nfy`6KhB!y1RRMvQI__z%?R{<=pOk7fS=vl12qM+lg&FY1UIr-;O+AfqE^7+p-2 zwGBxU6S4}wvWT7lI}P#gKy7bQm*ns71`a0C@DR``l;QEIakN1ep=x;eF(+ffn4LXC zvO^syi0700GrrzE0K4Noi0b~EFDlUpQ7;o@jS@}`rQo&XFzVe0H?o0)hZ^1gsr}u+ z!974*6h5q<{d}tlQHU|XEmXWb}wKBaJc60Xk|q zU*tZ^_YbVm&oSt*`)QKNwR~Df*Q8o`U ziV=s{5eijLZ0>hG2YD;QzsY8vsEsjuql}0+tp}C3r2j9Zn(+I06L033xScI(tJr?) zK}{qe$tOCPM`&H&yw}fLWINl`4V_V&CwD~%Vuxi87SV$kqDy@*Z^;dvv$iz4K-D=GUEon&Q=g+Q*+ z>t~rpHG?t25WZ2~tT=?!lVj=Wxc|4K1|GF(Cgd!Y7HBmyOr_PfH$j?Ri|8STfPp*| zjkaCnyg!6;BIkL-<2G*w24oIstg+>QLyg#f|2YJI{tqWIS^?dH2I>F+002ovPDHLk FV1jP3NV)(3 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..6230862c6b1c53d1230a9c3d4cd10796765e2d51 GIT binary patch literal 11843 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..e828dd07e550d549c486ae6123c4b692defd26e1 GIT binary patch literal 27057 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..52bad80d550b3c3726e69d3f1af3b41b9a57fde1 GIT binary patch literal 13411 zcmeHuS5%X4@MaLu&=WccNDI9qf;1)cCQW)1kRrWEuVR!WG^x@A>Ai#|9fbhWMS6!& zB}nf@Hoxot)tSyRC@t{$9(Ea3NHiZ{(kx6!!(_;F*;bm=gRqmjJh-w#WQQx)~|0qzDPArwdZks z_X4e@)29?XH|A%)R5_WbrnexuTLL*Aj`Fu&Ioh!|bTFiaZ``o+0V1F0ijf2!?M(;n zD*)LDeg+T&*Q{lNd9md=Tx<@$V2}!bDwA z{}7bEvRw<2McXaF7HJBl)?@|tDB4SDYN<;MHA>`k3`5phH>h-aGmohiCk~dVaYXNRD zY7*5P^mg6OSbo2(+IM2~ZET6NjqfIQDpn|&HC1CM?|B@6GzCb$?sLsEah`gF5@9dx zsegP=iwC|=UnplecU0mr)QmA#z)3yXE zsNstr2aNpOas*Vs0YZaYBuCqv70{P(+cygf36xE8fO)`^brkQTM|d;$YB=KEI1)oI zXA^?5-_^22X}~p)pW<9<=S1Td1K1pB!7{qI>minrlQ(ujk^{qX*JzCU~uA!_Hl?F>por+Bwhx<5)Em#sZjqL zcAi30N?7~#p|%32>ayHO3g|2uLch4o6IL8ccp)3{SqV7WKN>?NaGo4DHoc8l0+k;! zMpWZ1yzFWcqFqn*uJ(_Ome+b+``_V21$AY6MbjmdwB;!sw3$Q-;{{NCj|K?+94$om zF(0i}Q|>@3dR+h=_YPujt`kh+%?AqO5Sfd_E?($o;>7GvrBhcUy_52B24KW(PruMe zUKj#A02kRIe@lp7S-zX-L-9^wirGJX1KY-7*<QLmVcYnJh6%j8(~IK-;u6(08za>F``NBAmR$~Oc8j(gV!bmF+BGmBwi#etfRi-F-mO#@ro6p_qsZq*Q)2$Wo3cFLS%t z$_O>KrGy?Cywh=!aJG?!NG4fsU_AH}rWHA*Vp4J8P^Uw(^wzM_9Vr8D7gLGWM~qfAj$J7uU$s zoKl(?|1G==L=xmHfmMR<+*~1PZRRg6gVGnek3UZ z1)DZkIX)zHy`}xW-_sTJmGS|@S#<8S5`nb}yvI_c%V^R}(k=6g3r|`xsKwJNaWotf zS@jr_n%=^jm3UlNe3h9$Z;{dd>+Kg_IKi=S-5+`d3;n92KorJe zc>UdyC}k4FaRU>t$XbPzXfnyS+xR-7%*WOZ089I_FS9oV znS!tB-5v0dEmeva%hVYLX8U)c*1Xd4ux~HIPoNu@@t4)4!^0<78he_Kcc6#Z$2aBy znnD-|IB{Bu`j}zt6&#$FQ()v&c*%&t;4?V;)c0PoAHm6SL1k4}ksU=TxD?_Ics0D+dpV*%h$q8*#ND zx)jzbw%4K0l;D@c((Asd>QY7oB;Z(8V4>{!q<%58IE5aChl$a(l@Ppv&IKSN_`38q zMkr8g>89P(QCb7oZlxhnfYnmq{EM_Pwoevj<5XHw_y$aWlR!k(PBpt4QS47rP|1N*-$jb>TUSD%aB*jtMxpxJRdv)oUW6+FpdO*N``4XAVWi$Uluec{RgqBVGCKveO*;g@o zK^Q(P?vpS$=)~Yq ziPY;g2F?UlQQ#DZ+Zgwdj+WM@<=8cH4%9=Xl(yY3U!o7l&!YIZzw`!cu&Ov~ez6)V z5FdTfJoMtr1G%nens9yB^7;D*3{cvDQ^4q8BusG6nCVy-VK*WtK&|w9;po@Hllt-i zFk9e}56pJwJ+-1R?W!X%v|I9}tEY@;cDY(|ehj}tzt*sQ*~w3ab7|wfV@tpz#PeIK zv%3CcSyk!&qj%mp=P~vcKZ8wmLA>ySX5X~*xa1kc5mQ7CvNc54y_Rb1nlu-(C*2`K ztJT^o{YF16E%%W)5tn$XP0A|=t`4)b%TJxO3Ri&KPc6aE^DcI4?OX`fQM)q73%BW*s1CSB3fehbdcnbp_g|>zl?Czkq!lq`}R8f zKPB`*5quC|qQF zlCeyB+p^(NhHuSw*;HJ-YZJ9)ZZLG8%Yd|x4`mPCdV5Rt$aHpXc0-}sH(Yt=63E;y zdoxyECb7@Wo_gF!-br|n1Nc`|(d8?i7ap?71%9IyA|RS{4kgLPl9^wNwSE8z*G@yqO-B6r1H<=WY!Ld5k|JBh8lcR zX))vvZqb^Wyc)Jlc|JxPFNuO$>!s~#Tkkie$C`0!QgOT;C_7Pc@>QAZh;tI`ZNju! zY>BhCEte%0bZAKVCeM$y-~du7{mq;NBKa6NJNRwORXYIi2>*f%`gAz7;f;rR56Sgr z*OYP}2S-35LmHCUm;oX8tF-zlM}=wEZ&;gfuV+e##;Z-?hpNrscN_> zt;S6dFuyHzQ{-mV6h+->7`@lJqe5@aL_`R79ShbcJ>?slu6g!ju0h}<1-Q0BZ~C2sj3T2YOu&1pdl*qkYmDL z`Z5i2vedT6l*_SI(bVJ?#bX7EzDr#Qq21U_$`oURD##x0c|Xt*%6TS8_iN*6b9y*b ztzh8=r>@`d^N*Cve^rGj8$Re{{^r#4HYAl7O8Ua9x5G|dkV7l^ZJ21i7-S~=Ea_p+ zp7FQ;f_;c?8T^7)+Vug778lYkZUlIRldX)he~j01N&nl-456)wfB4IjUP;sdBPDd! zlKE3!H9?wig$@J##)ce|2v>lzE+aq8bcfkO)*v`E;Cu0!NmR(Bg>#C)tc`UISRH?` zeo>_@J~1&xKbK}Rupz4zkmOmqbN|pGO-a=hn}hWISC{Ipa`<#=J#ZAhE&t+%xke4= zHQVb{@j7wHi^k@dTXN#*ywj5hu%q~3Z&7znuBmMWveJ9lZl;rS#EZ}RJ+vgpccHsF z^mBSS{&+WFw$1a9=tDFFSClr6AuviN%Qv6K+=^JUCzkB_;nD2y(fsz@qQj>hcn5TL z487Uw9Rob^LjsClP;$hn(UPJ)FR|9SBe&20z8Tx^@dfv23X)C0pM^89dDT_e!nG=x zDEU9gE{a+=|FO2B96Xp!4PVYSiIPmAcjs==RYAmhsrtf8A0{Ojt z3D7)sgf&x!gM|n3?J#g^Wnv8C(dJh=b;~ziE6=HGg~1xGn2-(}XTP$U|GSXpos!SE z#i!laraN`0tK?;DF)IU{y^x<9ep0>KN;ua%E2YK*=N^E*b*QsiNcV2;+4ar}x_t^g zJZ)i|s&UkilP$ZBUo@x?cjcE|<%3z8M=Lh4x@e9}e+DwkxiN(gJ+V$a4_4?A@NvlE zg>x_3Pi3A4*~(csA7b#Kh(6ys_P0(8b}pxvXhz-EQ*uPYZIt$nx6iRUk=>gL6+vXC zEhLQ3+5>AM%=$Xxvl4Z%!}4QG2i%{M-f!fQnPSa;GiDh_1Z$#qHqdSn8s?JaOnq#M z8ss}mWokU$;iENtx|Y!bC}M&z6mx(uXvmrsznC7fws3}FmU2@A?Lk6=W z&)0j!u!quyVLmKuAL$nzZd-sRem;pU2DQk>_Hl`|C|}`$hu)%C8#3iQo>_9W1H@eW zL`-+Z>cMnim6a#rqCzvd{^bXNx43Mh?s(<5b&I`$Yw;?A;unE0Mor)?b~p;E<4R^5 z$F1ox@>6xfRWI|Uvkw`YXpN&^&U4F*@O<67i~fg#01 zyCF)^Ui}^_{~r5}cwLjl`scEz;pH^;k|K(BM7WHE@n?Bu8ataYRQ2{;0CKIL`sFDd z?v&(hXc5`ESA2(R$M8~3*{0Vp4Hmz0D;8)MSY4X;j4(#TEO54*9FtGFZ^^L3t2U*? zw6@Jm)!OcJ8R1UKh;;oaJV?ZKu~aCjsk)*&O<=n$-tXk@XH(|~a2HfNUufuUI=iU# z0c&qjv(mFBa>ez{YlFSFsWmgFzhd=G5$Ed{GE`RcB`N|UjeDlpU!rkp3bLg_KF+sJ z=Cd2YK%#f=YVXPPR@R>eNXt7pK2l-ZEiow&*DS0>KgI?&%Femx#7TcnFa0}6RjAb5 z|9t+O^Pj6$jcl?{i*&AMJGp2&hEi@1--?SGon(?+DZEV>sFg>5yc9_DEQF`ZY5}WP zmUE0C^I9^A@}tG*LKsh$61#>p>j$%Lm^4$p$0;gW!=!ulcR3dvv3U-NWg2Cdg0tL; zshK88-jQXL_A_`DwXd@CA2P|9aEX?!UirlYliU3e`-t;`=u~cb zjZtmJ+L0xh!a`E?)2hd@v`$ufH`rJbOP=VOYUiWI3CxwHg#A4EYhS@$syY~}Uot?b zBy9mHn>w6toB%J~v0`8tJ3sPj{b98!ecfbWDS^Y;xWt^)7)qJMFz!+?^rf2kL<0%G zZ-G)#Ig+UcllwKnFEIpZt-#X&FSf~|khczi$hc>=y=FZXUDCUy42gOGO}MNh0v#IHhc>$vj-rcu9Pt?+mKke9P*nT7eR`XC*RK`TT4^zsCoDmyRb=)VsD zRw_I+PlfYDwGTbs(oKV}@}4Qv$NEy`QWtVywzagXV)dEkH)r&w_7dlG16XYIfAIsB zlxDpB6^5D7$w^**V2w0)RxQ#weTH+>7KE4`TaglOj;o^ug`|kOxu7S=Bm4n3y09+o zbRyp!kBwy6?4+$*g74L!UOV4d`Z>{m`Mi_@LToI}$Opal7LMR2)E)O$ih(Xl)kB^` zMp=0z|MK9w)3#*7qdah@!%%K&G{@7532i6tOuvIthX|2ZXUg^rmuT$wdasrrD@zck_rbUatKQqEmB`SsOe6|<4vo?#V zPP1!P6_-|@#kJ1;1i1@wP4*X^o!tP*w>Z{&$FYik!j>HYH^#8_?$iO1t1JtS?}yghy>8j|O*=L&Sft#KYF0FjaM{=eNfEuY zhi7gpQbbWTRzoK0lgL&2jeIyLDja9JZC_ZksDX*}0|9tgSAIX?ExuRoXG3!{ z!>0)@^wxC{$w6R$vLpzTrj0JBw98%(J!S0W?mKOzBF3P6Gy6{0T2d_$ZukG!Hi^aX?~@^FS%1l8r%PPqUfvem=7LyKsQZ*t3fyvsbi@b%|OyrpbK z%n+srdLDlG-&KM07c7VmGscBt>9t)f$bcJWXo#mirc20rlrnU70lhtseH)uc)(^0NuyS`mrt>Ss+-&j2 zdwc!UFYp6gMrN@SVPE3ZRZvnmL{w4H-NydTcI#Yl^iU2#cPa^VA;i=~3Q90II^FM& zn;`9RVdwFfn4!~`(Bm?EVUSX<-t$YpKUKX?=J|GON9-*TM#f<^b%YpunGmk5Mp@Y? zfNWv*?n&l79g6)utlO>51CQ#6YEGiJ!oj!cjf{uqMWJLIfNyB#1(4>O!68Y3a3z*P_2KLm~&ueVD_6 zeoHshRT{_Grb&{3%c`I>8N^u~P4h~&B5-pwf4(b|6#L{XiAN83p3J(f9Te=mw3<5G zG+nS)+kaH}WcE{Em2_%=><5N>=6qNY^2kuB;%TPkr0Ep?JGFMWLgT3mez9JXH2P7rEs}<(mbmR3gTB&37`@74T!Ys@dw> z+T}k$e!qqP$o7s`bzoJrw)9Ll>zvgjb#~I{GN2)G4N~+nsJdb*Sz|6+o0PG8wJA?N zY4_C#^@1;_{~@s*b&n=H^4m+{3-*qAiWj(217G`OIW@H!nQ;(8``r+>O7MkOAfZWX zha9SMBFjY)$jmrB6L#VNavSs1*Di}eYpZn))uGXu5L0|{q6L;|5tKpiv{ZEtyx|<$ zjpbw6e~5v|QkTDdaD83R>_W*4?^7rBjp~V{?4|>MOwZ1Ml4>I4LfKHo-&}|>Uvb}P zioFrp%e6%xwyCCKGWAY&<7jhuoU14^a%VXz{d=f)woY3nTWm`tnU#a`_a>9?3)-e=@p0-Z&uVr>}jq_x^^Y1FUrXhy7NC2;OR z%=QIC(P0zAXAx~i8Qg$8BUe4+JDhFesTaaWulRGYCSp|%HM6X&me|a=?{SljyVOwO zi479iKdy2Xer@|9GC+WmuLZqHeO5Q{OxJXyC3V#j(20Klfm$xIbp5Qv+c7s=3bCRe z%xB_|>ah9Y_0YilCXGC~MW{{wMOsCzanJ1d0`NW`UR~7f&{@&KNeh{K(DLWZMK{z9 z-G5WYEe%RyOzxnNFr3vYnPg)&0}2){^1!=ML9Bk{<60_5(Jyi@yz(&nsV{>VMDC~i zO<5a?{fvKO-5rBZy^A#DbiZeKXfR>%eNxixo#?w$4z29k%DRz#_7jV~;g8H`PYgg_ zT9_zi@!Flo(DUxh0g1|SFE0|C-0{!05P2haD{Ehq2}_%dmYMe1SX)8$dMQTin?$oW zpA0z@bj7Jf#n?;4CpcgiE70MsU`r_PGzc?1<{KAoz+q)Yjm~FM+%1uxHG~B$i%azU zKoo)myXVUyz7;3lcUlQD_it7MzBaYdZWPoyrXBHuW>d;de`nS)9Va})O8X9PQsRoA zg+8wtf**L;w>&uPLB#f=|FV!8ZD$8LoA`Y}=aM|TO}e=cQt@5X|8bTedsp&KV$H?1 zz1;hY<@<(=%d~&%>ir+MBITn+t}?o2Un_n!wwDj}83}u@>wgtA9Dw1+s4plvVX>vY zq!p?KtSqxj_HuX*gf`nd8~5oBi-C&w-8E%-r%LR5+sj+ihaJ*G*-5b4w2kVsvDxkh zBmHvOM%0=d2X5^u<{2;f`1L-Eg~D`U?PmEF+w4$`V4M_I?t`e81>5t@-<=DFnHZrm z8@!~7?qI+_9;3)?*F{OlV`Y4WdUa|KSdCWGU>LHAk*le!u9pc*tF2R;I7pnU)GeZv zKBLM!fA?hl_L}m$wKLL_g6)r=S(jko@F@u_GGN;dZb_qPQJ(H4fR=8{Rax5S6BU?f zs;VW7?&n5+4IO;XHAla<@(`I)&eM5F!v^!#NNC8Tw?m-TUMsqLh9~@bzy%YG06EGF zhD&-WS&V2#M+UsKbVz$wG{~1O_Qy5=xZ<))@pw@}k;vgoggy5wE9QSW@V-Zh z)=T%yr~UBOxRt+4zPlgFekT2gYw{#eE}N;ER8C&GQ$ ztiGdZfWi=)$~^Q+M#zCzx5`Cf#JOi!Y+EAJ$GcbOuq9#p>>UCU7sKOTde)&X*+ba; z&e=cxP!ZR@)vC5Bv(%xrai0vA^oMU1#Ulr~gS_?XJ~vmC2%pgOCwn88eLop3RV?zm zrJg0@2v^wM+4^m}@!F;Lu4B5-nlFeLlMu@>ryX1(x^an`yG1#oe=*p&%o!OQnM7>B zh{UwR`U=eYOj}HENTXLoxdJB&LP~d2TK+C%EW;a5tIDw|97r>h=vz9HjG)s0^b%85>7d*vFg ze19ia#(AaiexycILGr8!^mG~8i?0x;2}-O3jppKD{iRXZUSoQCkYxp`5Z! zqE$53i_9PNJb@iCL*Uxhk8)XA)Qfn&R$H5$HCRCCG5^FDJ^4kJX;! zP$C>&wD9WVT3X{nE{VW4YoCu0+^ZhiDB2SHn6BBzx~I4`@bg;?d%u{{f0`T7tUl#y zX51^o3U>VV}*pE*&>W!V1c;Gq>^6pv6Vt?2@i4MI5(USwBMDNR{<;2qW z)JLCH{A_6i)1A;z1!fQrHm$!HFe8|ot<0y38*ZJ#Q2~lr)+b*SO(q}6Rr8K_6n*_% z^p-%v_zSIsB0=5Tq^6HDh65RH@nPi3YcCp>d?%?--z-1E#nD@+wt6+SXn>#Ad8pk- z&;(&0OH%#P{@Hs*FX48eJPp=W(=}s(bI0VJ6Wgk-hr`J0T=51^SG=TRmSO2XQMmV} zq?mBBRQ3K^GANQ^dze&_R+K%eqc(7%6ubh=?Y8xZENsmu9*CkL%6QKwbVk$d9vTZ$ zO(JKVm$P+wAmgl|z5B*mf&NUd__$D4ZZ{wQXCiL4xllXQa#r!|N7s?}Sk~ugWyzB* zK@+4eaok_bnSR3;Ih7fPEN|=@_Tql-gWxn14HG6T8M>ecHv*nZnoKgoN1Z z*S_e{=9POH2vXPxKW&0WJZ^@QjL%D#%zp6%+w6}2PUGbcBY$svo5rdB$mUyLC(dCGJjk1vvXD3th8D8W%lJyq|(TwFH)yEIo!u{ z757a(2Llsr zUN0}i*uQ38D$~@yrscXkvGvlKoJ>~tj#ZZ&iS)-KEhCJ($VODRmVI)t_mUCV=vRkT z-A8W{n>5#FU`@`q>GAa`^u>^6D&wP<9}203la&F_0G`Y?eoNUcAQc1&g&=y{!uDJ3 z-ZBPaYa%O1j;CbqOR{X=+ZX%Js5|YU-V=iw2r*NPxSeC35cGftCJ)z#Q5( zXU@Cq=xG)t>xi8_^O< zxgaG_iw1cbd7jlEJIp!s-)l3Y8x-26Y+csxookV=PE#_>qDnb+k_{!%sHQ_oN1-OO{tk3Un5Bs3r$8_4Ovi9C+r z+9MDA3xtJ7M-e<+Ft5qp&-*_nTtjJ>8u;+LARruEUOhrMLwJy#QNy(M@|Jecv+N0> zG)IfJor^oNW5w$beeZ8PI>WcUm}Tx4nEzi^Tcsm^9Athx}dfpP7*YA&_x@s@?M_@nm=DLlFByHUUl-OT zU}^h?fz=u47pj0Tk`)rz?+U@@cW2GvIDF5fj1mrCnLm1PM-vfFb-ttZKDrMtz_22L zZvCEFY2cv)QTZ;gWZHhNAnO;|#7#HTXhQL==M<+Xpd%9`7G}RgCWdBdvm48%Db z_V6zJo7I<6b%B*Q{DVMI1_P;V+yNJbCNz(xVz|3rdk9b6+(z>)(R0_J_oCuzf4b6X zE!7b8O}{nI?7h(t_xz@&)l|h%^@Jq@@=@&VBrHR{FN;_7gv|J%6wG9`6L67a5)~iY zIyA#Z2<~ejaO4T!X131_C0YrOHfcA`Z{U|$!D%*{DkASlj(A>%n)8ikmDAf+E7%sI zUCq}9(2RQRyJhYVGX)2xXmfRN_vdo7MWB`<+a{uwJ66)E_K&*3O;zYm4N=reOy@B^ z)~1o^U>4S7=hr?>Kx433UurTB`9{9@_?K1(nT6V>?e>i8*idCm{vV9LH7btH?=D$_ z>jgG3#p0A0Tj5<*u^BK_tSt)?q(})6u)VlL8+O=fxZSTO$4xbVXfW{@yDYv{OJ+}d z^z*|X(d)N##R6zYu(bV@c6fy!N2{~#a2{nhzj#}bw}#6Ye|b$NeWOg3NnnAkXW><_ zFl9GSivt|{2G-bm>;r;wR~>f3?E@_TRf-R`R>%~vVhG4W;dJGiO=XI(uUApP4GAX& z`nAzcuBo4?l-+SeOIYZd4?F2+(LmOog2fSS!m-SzRb2!Iz(#d#QqeSVdH+#>B_KWuA&&#+jD{#&Mruy>Alp4Do z2&3)xcm7wEU@Dek7PnqpQ$ix{kb_d@Q!}`SiKafn11xS6z+`Ne)YXkvX>QrYh66 z{~we$6py<5A?-Rs;!N-b#b%zAi4|2F4lM*W#HPqstd-(4d30GrB9J+XGsEu zreM%@j(>9DMRQZ7F?g=qO3X;h!2c>nc1dlB|_9&G>1xxV}TP`9}!(ia3mo>{|-Z9S(FaS?`Z<7b8pv`6i3@ zvqSHtNqJE4Gh?)mZi&Z(F<^uScl zToXst2_qE4+A?uzK#$%5*A`2zWA@8WiIg=-rt+2P1$#n13ch!vF~T*2@+P$8OTpFD zGTz<6r?ZCaQo5;ZJvh(w!fkT}%Ci}mrwl}k-5@dk>yaTbBJ368`eb&NHW&B4hYw(9 z#OUrydR7U{s5R|OTk$FBs%v{Yp*%T=E&0a9zdYD5oyCd!d@5R!IN1fG!&j0?ZL!37 z{EZ+tM0tg9hnA9PWd}N zyEZ!WvJeL+TBSc9jU%bD`0Vk9c(8--_VdLRCmMZ_I*I$+ zQm)_YJJ7mWi!VO|Vsv%ejqFrKf4MIF6M`=6E~D++gi`bN8qh&X6xR*xS!kPpTkDY^o&0*;C?O@M1rBU zY%9oIY2^vD(ndhU>T_R_H_dh0U9w|!?ZK1M+IPBF-pX;83^10B9?`xX?HfPNz9SPz z<=$F+J@i(_UGo3qjx5<9T4wn`wPy4a^x&e!Lqvav6KXSRSHOjyqrE zQ6hdrtkc<77CHlaQ#GV+M_rjHb2I8Qt$Q19Uh5vC51rplkmQ%0Y0g^;=@M1s)~ZD9 z*x9ro)8ueko9-hk+i(iCKG}N0bTbwSp@&$i4MBV=etwD{ODjV$g5%=MK6QG+4Uc-c zX=YP8*Pi1NG|Jw}-{QnCi|L3bS|6_sVe`o2%8*mf8 Yjc5Hj0lv(QTOIwc=yEPIFrdfXHI4^naRw_ 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 Ye?$drxTd&M?f?J)07*qoM6N<$f|+7|)&Kwi literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..6542cf1decd42a38d16a8937de3c84f0675353e2 GIT binary patch literal 19066 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

`; a template using a missing value fails the render. Release notes come from `git-cliff --context` with `cliff.toml` (`GitCliffContextTask`): needs `git-cliff` on the PATH and full git history. `gradle.properties` defaults to `nightly=true`, which leaves commits unreleased: pass `-Pnightly=false` for a stable render. +- `packaging/package-linux-tarball.sh` packs `spotube-linux--.tar.xz` (the AUR and Flathub source) from the GraalVM distributable and the rendered `linux` dir; the release workflow builds it before the AppImage step. +- The publish workflow (`workflow_dispatch`, input `tag`) renders once, then pushes to Flathub (`team-spotube/org.spotube.spotube`), the AUR (`spotube-bin`), WinGet (PR from `team-spotube/winget-pkgs`) and Chocolatey. + ## OpenCode Agent MCP Execution Directives ### 1. Code Discovery & Context Search diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 28845360..0479ace4 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,5 +1,6 @@ -// Build logic for the Linux AppImage bundle (see DownloadLinuxGtkBundleTask). Lives in buildSrc so the IDE -// resolves it; an `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio. +// Build logic: the Linux AppImage bundle (DownloadLinuxGtkBundleTask) and the package manager manifests +// (GitCliffContextTask, RenderPackagingTask). Lives in buildSrc so the IDE resolves it; an +// `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio. plugins { `kotlin-dsl` } @@ -7,3 +8,7 @@ plugins { repositories { mavenCentral() } + +dependencies { + implementation(libs.jmustache) +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..ac722dd3 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,8 @@ +// Shares the root version catalog (gradle/libs.versions.toml) with the build logic +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt new file mode 100644 index 00000000..88d13ed0 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt @@ -0,0 +1,92 @@ +/* + * 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 . + */ + +// Writes git-cliff's changelog context (every release with its parsed, grouped commits) as JSON, using +// the repository's cliff.toml. RenderPackagingTask turns it into release notes for the package manifests. +// Needs `git-cliff` on the PATH and the full git history (CI: `fetch-depth: 0`). + +package org.spotube.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject + +@DisableCachingByDefault(because = "Reads the git history, which is not a declared input") +abstract class GitCliffContextTask @Inject constructor(private val execOperations: ExecOperations) : DefaultTask() { + @get:Input + abstract val executable: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val configFile: RegularFileProperty + + @get:Internal + abstract val repositoryDir: DirectoryProperty + + /** Tag for the commits since the last release (`--tag`); unset leaves them unreleased (nightlies). */ + @get:Input + @get:Optional + abstract val tag: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + init { + group = "distribution" + description = "Writes the git-cliff changelog context (releases and their commits) as JSON." + executable.convention("git-cliff") + // New commits and tags change the result without touching any declared input + outputs.upToDateWhen { false } + } + + @TaskAction + fun run() { + val output = outputFile.get().asFile + output.parentFile.mkdirs() + val args = buildList { + add(executable.get()) + addAll(listOf("--config", configFile.get().asFile.absolutePath, "--context")) + tag.orNull?.let { addAll(listOf("--tag", it)) } + addAll(listOf("--output", output.absolutePath)) + } + try { + execOperations.exec { + workingDir = repositoryDir.get().asFile + commandLine(args) + } + } catch (e: Exception) { + throw GradleException( + "Running git-cliff failed (${args.joinToString(" ")}). Install it from https://git-cliff.org " + + "and make sure the full git history is available.", + e, + ) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt new file mode 100644 index 00000000..27b57fa3 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt @@ -0,0 +1,264 @@ +/* + * 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 . + */ + +// Renders one package manager's directory (packaging/) into the build dir: `*.mustache` files are +// rendered with JMustache (suffix dropped), everything else is copied as is. +// +// Template data: +// version, tag, pkgrel, repositoryUrl +// artifacts..{fileName, url, sha256, sha256Upper} release assets found in artifactsDir +// release.{version, date, url, groups, notes} the release being packaged +// releases tagged releases, newest first (metainfo) +// groups[].{title, entries[].text} changelog grouped like cliff.toml +// #indent2 / #indent4 indent a multi-line block (YAML block scalars) +// +// Missing variables fail the render, so a manifest never ships with an empty checksum or version. +// HTML/XML escaping is on for .xml and .nuspec output only. + +package org.spotube.gradle + +import com.samskivert.mustache.Mustache +import com.samskivert.mustache.MustacheException +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.security.MessageDigest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +@CacheableTask +abstract class RenderPackagingTask : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val templateDir: DirectoryProperty + + @get:Input + abstract val version: Property + + @get:Input + abstract val pkgrel: Property + + @get:Input + abstract val repositoryUrl: Property + + /** Release assets by template key, e.g. `linuxX86_64` -> `spotube-linux-6.0.0-x86_64.tar.xz`. */ + @get:Input + abstract val artifactNames: MapProperty + + /** Directory holding the downloaded release assets; only needed by templates that use `artifacts`. */ + @get:Internal + abstract val artifactsDir: DirectoryProperty + + /** The release assets that exist in [artifactsDir]; their checksums are part of the output. */ + @get:Input + protected val artifactChecksums: Map + get() = artifactFiles().mapValues { (_, file) -> sha256(file) } + + /** git-cliff `--context` output (GitCliffContextTask). */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val changelogContext: RegularFileProperty + + /** How many tagged releases `releases` lists. */ + @get:Input + abstract val maxReleases: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + init { + group = "distribution" + pkgrel.convention("1") + maxReleases.convention(10) + } + + @TaskAction + fun render() { + val source = templateDir.get().asFile + val target = outputDir.get().asFile + target.deleteRecursively() + target.mkdirs() + + val data = templateData() + source.walkTopDown().filter { it.isFile }.forEach { file -> + val relative = file.relativeTo(source).path + if (!relative.endsWith(TEMPLATE_SUFFIX)) { + file.copyTo(target.resolve(relative), overwrite = true) + return@forEach + } + val output = target.resolve(relative.removeSuffix(TEMPLATE_SUFFIX)) + val compiler = Mustache.compiler() + .escapeHTML(output.extension in XML_EXTENSIONS) + .emptyStringIsFalse(true) + .zeroIsFalse(true) + val rendered = try { + compiler.compile(file.readText()).execute(data) + } catch (e: MustacheException) { + throw GradleException( + "Rendering $relative failed: ${e.message?.trimEnd('.')}. Templates that use `artifacts` need " + + "-PpackagingArtifactsDir=.", + e, + ) + } + output.parentFile.mkdirs() + output.writeText(rendered) + } + } + + private fun templateData(): Map { + val version = version.get() + val tag = "v$version" + val repositoryUrl = repositoryUrl.get() + val downloadUrl = "$repositoryUrl/releases/download/$tag" + + val artifacts = artifactFiles().mapValues { (_, file) -> + val sha256 = sha256(file) + mapOf( + "fileName" to file.name, + "url" to "$downloadUrl/${file.name}", + "sha256" to sha256, + "sha256Upper" to sha256.uppercase(), + ) + } + + val changelog = changelogContext.orNull?.asFile?.let(::readChangelog).orEmpty() + val tagged = changelog.filter { it.version != null }.sortedByDescending { it.timestamp } + // The release being packaged; nightlies have no tag, so they get the unreleased commits + val current = changelog.firstOrNull { it.version == tag } + ?: changelog.firstOrNull { it.version == null } + ?: ChangelogRelease(tag, Instant.now().epochSecond, emptyList()) + + return mapOf( + "version" to version, + "tag" to tag, + "pkgrel" to pkgrel.get(), + "repositoryUrl" to repositoryUrl, + "artifacts" to artifacts, + "release" to releaseData(current, version, repositoryUrl), + "releases" to tagged.take(maxReleases.get()).map { + releaseData(it, it.version!!.removePrefix("v"), repositoryUrl) + }, + "indent2" to indent(2), + "indent4" to indent(4), + ) + } + + private fun artifactFiles(): Map { + val dir = artifactsDir.orNull?.asFile ?: return emptyMap() + return artifactNames.get() + .mapValues { (_, name) -> dir.resolve(name) } + .filterValues { it.isFile } + } + + private fun releaseData(release: ChangelogRelease, version: String, repositoryUrl: String): Map { + val groups = release.commits + .groupBy { it.group } + .toSortedMap() + .map { (group, commits) -> + mapOf( + // cliff.toml orders groups with an HTML comment prefix: "Features" + "title" to group.replace(HTML_COMMENT, "").trim(), + "entries" to commits.map { mapOf("text" to it.text) }, + ) + } + val notes = groups.joinToString("\n\n") { group -> + @Suppress("UNCHECKED_CAST") + val entries = group["entries"] as List> + "${group["title"]}\n" + entries.joinToString("\n") { "- ${it["text"]}" } + } + return mapOf( + "version" to version, + "date" to LocalDate.ofInstant(Instant.ofEpochSecond(release.timestamp), ZoneOffset.UTC).toString(), + "url" to "$repositoryUrl/releases/tag/v$version", + "groups" to groups, + "notes" to notes, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun readChangelog(file: File): List { + val releases = JsonSlurper().parse(file) as List> + return releases.map { release -> + val commits = (release["commits"] as List>).mapNotNull { commit -> + val group = commit["group"] as String? ?: return@mapNotNull null + val message = (commit["message"] as String).plainText().replaceFirstChar { it.uppercase() } + val scope = commit["scope"] as String? + val breaking = commit["breaking"] == true + val text = buildString { + if (breaking) append("Breaking: ") + if (scope != null) append("$scope: ") + append(message) + } + ChangelogCommit(group, text) + } + ChangelogRelease( + version = release["version"] as String?, + timestamp = (release["timestamp"] as Number?)?.toLong() ?: Instant.now().epochSecond, + commits = commits, + ) + } + } + + private data class ChangelogRelease(val version: String?, val timestamp: Long, val commits: List) + + private data class ChangelogCommit(val group: String, val text: String) + + private companion object { + const val TEMPLATE_SUFFIX = ".mustache" + val XML_EXTENSIONS = setOf("xml", "nuspec") + val HTML_COMMENT = Regex("") + val MARKDOWN_LINK = Regex("""\[([^\]]+)]\([^)]+\)""") + + // cliff.toml's commit preprocessors turn issue numbers into Markdown links + fun String.plainText() = replace(MARKDOWN_LINK, "$1").replace("**", "").trim() + + fun indent(spaces: Int) = Mustache.Lambda { fragment, out -> + val prefix = " ".repeat(spaces) + out.write(fragment.execute().lines().joinToString("\n") { if (it.isEmpty()) it else prefix + it }) + } + + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b717523d..5d3ad20e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,6 +25,8 @@ import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.gradle.api.tasks.Copy import org.spotube.gradle.DownloadLinuxGtkBundleTask +import org.spotube.gradle.GitCliffContextTask +import org.spotube.gradle.RenderPackagingTask import java.io.FileInputStream import java.util.Properties @@ -76,6 +78,37 @@ tasks.register("printVersion") { val appVersion = appVersionName doLast { println(appVersion) } } + +// Package manager manifests: packaging/ rendered into build/packaging/ with the version, the +// release assets' checksums (-PpackagingArtifactsDir=) and the git-cliff changelog. See RenderPackagingTask. +val gitCliffContext = tasks.register("gitCliffContext") { + configFile.set(rootProject.layout.projectDirectory.file("cliff.toml")) + repositoryDir.set(rootProject.layout.projectDirectory) + // Nightlies are not a release: their commits stay unreleased + if (!isNightly) tag.set("v$appVersionName") + outputFile.set(layout.buildDirectory.file("packaging/changelog-context.json")) +} +val packagingRootDir = rootProject.layout.projectDirectory +val packagingArtifactsDir = packagingRootDir.dir(providers.gradleProperty("packagingArtifactsDir")) +listOf("linux", "aur", "flatpak", "winget", "chocolatey").forEach { target -> + tasks.register("renderPackaging${target.replaceFirstChar { it.uppercase() }}") { + description = "Renders packaging/$target into build/packaging/$target." + templateDir.set(packagingRootDir.dir("packaging/$target")) + version.set(appVersionName) + pkgrel.set(providers.gradleProperty("pkgrel").orElse("1")) + repositoryUrl.set(appRepositoryUrl) + artifactNames.set( + mapOf( + "linuxX86_64" to "spotube-linux-$appVersionName-x86_64.tar.xz", + "linuxAarch64" to "spotube-linux-$appVersionName-aarch64.tar.xz", + "windowsX64" to "Spotube-windows-x86_64-setup.exe", + ) + ) + artifactsDir.set(packagingArtifactsDir) + changelogContext.set(gitCliffContext.flatMap { it.outputFile }) + outputDir.set(layout.buildDirectory.dir("packaging/$target")) + } +} val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/") val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest" val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30e39bf7..e27aeb1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,8 +77,11 @@ vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" mokkery = "3.3.0" +jmustache = "1.16" [libraries] +# buildSrc: renders the package manager manifests in packaging/ +jmustache = { module = "com.samskivert:jmustache", version.ref = "jmustache" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO deleted file mode 100644 index f6d76556..00000000 --- a/packaging/aur/.SRCINFO +++ /dev/null @@ -1,19 +0,0 @@ -pkgbase = spotube-bin -pkgdesc = Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! -pkgver = 4.0.0 -pkgrel = 1 -url = https://spotube.org -arch = x86_64 -license = AGPL-3.0-or-later -depends = mpv -depends = libappindicator-gtk3 -depends = libsecret -depends = jsoncpp -depends = libnotify -depends = xdg-user-dirs -depends = webkit2gtk-4.1 -optdepends = yt-dlp-git -source = https://github.com/team-spotube/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz -md5sums = 475b1ae9b08f27743a4d4749391ae3db - -pkgname = spotube-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD deleted file mode 100644 index da28fde8..00000000 --- a/packaging/aur/PKGBUILD +++ /dev/null @@ -1,40 +0,0 @@ -# Maintainer: Kingkor Roy Tirtho -pkgname=spotube-bin -pkgver=%{{SPOTUBE_VERSION}}% -pkgrel=%{{PKGREL}}% -epoch= -pkgdesc="Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" -arch=(x86_64) -url="https://spotube.org" -license=('AGPL-3.0-or-later') -groups=() -depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') -makedepends=() -checkdepends=() -optdepends=('yt-dlp-git') -provides=() -conflicts=() -replaces=() -backup=() -options=() -install= -changelog= -source=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") -noextract=() -md5sums=(%{{LINUX_MD5}}%) -validpgpkeys=() - -package() { - install -dm755 "${pkgdir}/usr/share/icons/spotube" - install -dm755 "${pkgdir}/usr/share/applications" - install -dm755 "${pkgdir}/usr/share/appdata" - install -dm755 "${pkgdir}/usr/share/${pkgname}" - install -dm755 "${pkgdir}/usr/bin" - - mv ./spotube.desktop "${pkgdir}/usr/share/applications" - mv ./spotube-logo.png "${pkgdir}/usr/share/icons/spotube/" - mv ./com.github.KRTirtho.Spotube.appdata.xml "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - cp -ra ./data ./lib ./spotube "${pkgdir}/usr/share/${pkgname}" - sed -i 's|com.github.KRTirtho.Spotube|spotube|' "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/spotube" -} diff --git a/packaging/aur/PKGBUILD.mustache b/packaging/aur/PKGBUILD.mustache new file mode 100644 index 00000000..0fa2e183 --- /dev/null +++ b/packaging/aur/PKGBUILD.mustache @@ -0,0 +1,32 @@ +# Maintainer: Kingkor Roy Tirtho +pkgname=spotube-bin +pkgver={{version}} +pkgrel={{pkgrel}} +pkgdesc="Open source, plugin powered music streaming client" +arch=(x86_64 aarch64) +url="https://spotube.org" +license=('AGPL-3.0-or-later') +# libvlc + plugins for playback (FFmpeg demux/decode, GnuTLS for HTTPS streams, PulseAudio/PipeWire output), +# WebKitGTK 4.1 for the webview, and the X11/GL/ALSA libraries the AWT/Skiko natives link +depends=( + 'libvlc' 'vlc-plugins-base' 'vlc-plugin-ffmpeg' 'vlc-plugin-gnutls' 'vlc-plugin-pulse' + 'webkit2gtk-4.1' 'gtk3' 'glib2' 'dbus' 'alsa-lib' 'libglvnd' 'fontconfig' 'freetype2' + 'libx11' 'libxext' 'libxi' 'libxrender' 'libxtst' 'zlib' +) +provides=('spotube') +conflicts=('spotube') +options=('!strip' '!debug') +source_x86_64=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") +source_aarch64=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-aarch64.tar.xz") +sha256sums_x86_64=('{{artifacts.linuxX86_64.sha256}}') +sha256sums_aarch64=('{{artifacts.linuxAarch64.sha256}}') + +package() { + cd "spotube-linux-${pkgver}-${CARCH}" + + install -dm755 "${pkgdir}/opt" "${pkgdir}/usr/bin" "${pkgdir}/usr/share" + cp -a app "${pkgdir}/opt/spotube" + cp -a share/. "${pkgdir}/usr/share/" + ln -s /opt/spotube/spotube "${pkgdir}/usr/bin/spotube" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/chocolatey/spotube.nuspec b/packaging/chocolatey/spotube.nuspec deleted file mode 100644 index ec0ec79a..00000000 --- a/packaging/chocolatey/spotube.nuspec +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - spotube - %{{SPOTUBE_VERSION}}% - https://github.com/team-spotube/spotube/tree/master/choco-struct - Kingkor Roy Tirtho - - - - spotube (Install) - Kingkor Roy Tirtho - https://spotube.org - - https://rawcdn.githack.com/team-spotube/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/branding/spotube-logo.png - 2022 Spotube - - https://github.com/team-spotube/spotube/blob/master/LICENSE - true - https://github.com/team-spotube/spotube - https://spotube.org - https://github.com/team-spotube/spotube/issues/new - spotube music audio youtube flutter - 🎧 Open source music client that doesn't require Premium nor uses Electron! Available - for both desktop & mobile! - - Spotube is a Flutter based lightweight music client. It utilizes the power - of music metadata providers & Youtube's public API & creates a hazardless, performant - & resource - friendly User Experience - - # Features - - Open source/libre software - - Anonymous/guest login - - Cross platform support - - No telemetry, diagnostics or user data collection - - Lightweight & resource-friendly - - Native performance (Thanks to Flutter+Skia) - - Playback control is done locally instead of on the server - - Small size & less data usage - - No ads since it uses all public & free APIs (It is still recommended - to support the creators by watching/liking/subscribing to the artists' YouTube channels or - liking their tracks on different music platforms.) - - Time synced lyrics - - Downloadable tracks - - https://github.com/team-spotube/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% - - - - - \ No newline at end of file diff --git a/packaging/chocolatey/spotube.nuspec.mustache b/packaging/chocolatey/spotube.nuspec.mustache new file mode 100644 index 00000000..a864042f --- /dev/null +++ b/packaging/chocolatey/spotube.nuspec.mustache @@ -0,0 +1,48 @@ + + + + + + spotube + {{version}} + https://github.com/team-spotube/spotube/tree/master/packaging/chocolatey + Kingkor Roy Tirtho + + + + spotube (Install) + Team Spotube + https://spotube.org + + https://rawcdn.githack.com/team-spotube/spotube/69a310c78f5ceaf4eab7dfee98f187d38211c9ba/assets/branding/spotube-logo.png + 2026 Kingkor Roy Tirtho and Spotube Contributors + + https://github.com/team-spotube/spotube/blob/master/LICENSE + true + https://github.com/team-spotube/spotube + https://spotube.org + https://github.com/team-spotube/spotube/issues/new + spotube music audio music-player streaming lyrics plugins foss + Open source, plugin powered music streaming client + + A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. + + # Features + - Plugin powered: supports any platform or custom music service through plugins + - Community driven plugins for popular platforms, or create your own + - Freely downloadable tracks with tagged metadata + - Time synced lyrics regardless of plugin support + - No telemetry, diagnostics or user data collection + - Small size, low data usage and native performance + - Open source/libre software + + {{release.url}} + +{{release.notes}} + + + + + \ No newline at end of file diff --git a/packaging/chocolatey/tools/VERIFICATION.txt b/packaging/chocolatey/tools/VERIFICATION.txt.mustache similarity index 69% rename from packaging/chocolatey/tools/VERIFICATION.txt rename to packaging/chocolatey/tools/VERIFICATION.txt.mustache index 9ca4799f..4d1d6c40 100644 --- a/packaging/chocolatey/tools/VERIFICATION.txt +++ b/packaging/chocolatey/tools/VERIFICATION.txt.mustache @@ -7,17 +7,17 @@ in verifying that this package's contents are trustworthy. Please go to releases page https://github.com/team-spotube/spotube/releases -Download same version as this choco package (example for v%{{SPOTUBE_VERSION}}%) -https://github.com/team-spotube/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% +Download same version as this choco package (v{{version}}) +https://github.com/team-spotube/spotube/releases/tag/v{{version}} 1. get hashes. Run: powershell -command Get-FileHash tools\Spotube-windows-x86_64-setup.exe 2. The checksums should match the following: --- -Version Hashes for v%{{SPOTUBE_VERSION}}% +Version Hashes for v{{version}} Algorithm Hash Path --------- ---- ---- -SHA256 %{{WIN_SHA256}}% tools\Spotube-windows-x86_64-setup.exe \ No newline at end of file +SHA256 {{artifacts.windowsX64.sha256Upper}} tools\Spotube-windows-x86_64-setup.exe \ No newline at end of file diff --git a/packaging/chocolatey/tools/chocolateyinstall.ps1 b/packaging/chocolatey/tools/chocolateyinstall.ps1 index 8e84266e..f188e3cc 100644 --- a/packaging/chocolatey/tools/chocolateyinstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyinstall.ps1 @@ -5,13 +5,15 @@ $fileLocation = Join-Path $toolsDir 'Spotube-windows-x86_64-setup.exe' $packageArgs = @{ packageName = $env:ChocolateyPackageName - unzipLocation = $toolsDir - fileType = 'exe' #only one of these: exe, msi, msu - file = $fileLocation + fileType = 'exe' + file = $fileLocation - softwareName = 'spotube*' #part or all of the Display Name as you see it in Programs and Features. It should be enough to be unique - silentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' # Inno Setup + softwareName = 'Spotube*' + silentArgs = '/S' # NSIS (electron-builder one-click, per-user) validExitCodes= @(0) } -Install-ChocolateyInstallPackage @packageArgs \ No newline at end of file +Install-ChocolateyInstallPackage @packageArgs + +# The installer was only needed to install; don't keep a copy in the package directory +Remove-Item $fileLocation -Force -ErrorAction SilentlyContinue diff --git a/packaging/chocolatey/tools/chocolateyuninstall.ps1 b/packaging/chocolatey/tools/chocolateyuninstall.ps1 index 17b8e929..d1e9f55a 100644 --- a/packaging/chocolatey/tools/chocolateyuninstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyuninstall.ps1 @@ -1,17 +1,23 @@ $ErrorActionPreference = 'Stop'; # stop on all errors $packageArgs = @{ packageName = $env:ChocolateyPackageName - softwareName = 'spotube*' + softwareName = 'Spotube*' fileType = 'exe' - validExitCodes= @(0, 3010, 1605, 1614, 1641) - silentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' # Inno Setup + validExitCodes= @(0) } [array]$key = Get-UninstallRegistryKey -SoftwareName $packageArgs['softwareName'] if ($key.Count -eq 1) { $key | % { - $packageArgs['file'] = "$($_.UninstallString)" #NOTE: You may need to split this if it contains spaces, see below + # electron-builder writes UninstallString as "\Uninstall Spotube.exe" /currentuser + if ($_.UninstallString -match '^\s*"([^"]+)"\s*(.*)$') { + $packageArgs['file'] = $Matches[1] + $packageArgs['silentArgs'] = "$($Matches[2]) /S".Trim() + } else { + $packageArgs['file'] = $_.UninstallString + $packageArgs['silentArgs'] = '/S' + } Uninstall-ChocolateyPackage @packageArgs } @@ -22,4 +28,4 @@ if ($key.Count -eq 1) { Write-Warning "To prevent accidental data loss, no programs will be uninstalled." Write-Warning "Please alert package maintainer the following keys were matched:" $key | % {Write-Warning "- $($_.DisplayName)"} -} \ No newline at end of file +} diff --git a/packaging/flatpak/org.spotube.spotube.yml.mustache b/packaging/flatpak/org.spotube.spotube.yml.mustache new file mode 100644 index 00000000..d0744bff --- /dev/null +++ b/packaging/flatpak/org.spotube.spotube.yml.mustache @@ -0,0 +1,112 @@ +id: org.spotube.spotube +# GTK 3 and WebKitGTK 4.1 (the webview) come from the GNOME runtime +runtime: org.gnome.Platform +runtime-version: '51' +sdk: org.gnome.Sdk +command: spotube + +finish-args: + - --share=ipc + - --share=network + # Compose renders through AWT/Skiko, which needs X11 (XWayland on Wayland sessions) + - --socket=x11 + - --socket=pulseaudio + - --device=dri + # Downloaded tracks go to ~/Downloads/Spotube; local tracks are scanned from ~/Music and ~/Downloads + - --filesystem=xdg-download + - --filesystem=xdg-music:ro + # Tray icon (StatusNotifierItem) + - --talk-name=org.kde.StatusNotifierWatcher + +cleanup: + - /include + - /lib/pkgconfig + - /share/man + - /share/doc + - '*.la' + - '*.a' + +modules: + # libvlc for vlcj (the audio player). Audio only: no video outputs, UI or Lua; decoding and demuxing + # go through the runtime's FFmpeg (proprietary codecs come from the auto-installed codecs-extra extension). + - name: vlc + build-options: + env: + # configure looks for a c99/c11 wrapper for its build-time tools + BUILDCC: gcc -std=gnu11 + config-opts: + - --disable-vlc + - --disable-qt + - --disable-skins2 + - --disable-ncurses + - --disable-lua + - --disable-a52 + - --disable-dbus + - --disable-xcb + - --disable-xvideo + - --disable-wayland + - --disable-vdpau + - --disable-libva + - --disable-gles2 + - --disable-sdl-image + - --disable-chromecast + - --disable-udev + - --disable-v4l2 + - --disable-dc1394 + - --disable-dv1394 + - --disable-linsys + - --disable-dvbpsi + - --disable-bluray + - --disable-dvdread + - --disable-dvdnav + - --disable-vcd + - --disable-libcddb + - --disable-screen + - --disable-addonmanagermodules + - --disable-update-check + cleanup: + - /share/applications + - /share/icons + - /share/kde4 + - /share/metainfo + - /share/solid + - /share/vlc + sources: + - type: archive + url: https://download.videolan.org/pub/videolan/vlc/3.0.24/vlc-3.0.24.tar.xz + sha256: e7cab503d1d7d5849b89d2cf0e1ee60d0ef6d012407791b644b9cfc0cc225fdf + x-checker-data: + type: anitya + project-id: 6504 + stable-only: true + versions: {<: '4.0'} + url-template: https://download.videolan.org/pub/videolan/vlc/$version/vlc-$version.tar.xz + + # The GraalVM native image, repackaged from the release tarball (packaging/package-linux-tarball.sh) + - name: spotube + buildsystem: simple + build-commands: + - cp -a app ${FLATPAK_DEST}/lib/spotube + - install -d ${FLATPAK_DEST}/bin + - ln -s ../lib/spotube/spotube ${FLATPAK_DEST}/bin/spotube + - cp -a share/. ${FLATPAK_DEST}/share/ + sources: + - type: archive + only-arches: [x86_64] + url: {{artifacts.linuxX86_64.url}} + sha256: {{artifacts.linuxX86_64.sha256}} + x-checker-data: + type: json + url: https://api.github.com/repos/team-spotube/spotube/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/team-spotube/spotube/releases/download/v" + $version + "/spotube-linux-" + $version + "-x86_64.tar.xz"' + is-main-source: true + - type: archive + only-arches: [aarch64] + url: {{artifacts.linuxAarch64.url}} + sha256: {{artifacts.linuxAarch64.sha256}} + x-checker-data: + type: json + url: https://api.github.com/repos/team-spotube/spotube/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/team-spotube/spotube/releases/download/v" + $version + "/spotube-linux-" + $version + "-aarch64.tar.xz"' diff --git a/packaging/linux/org.spotube.spotube.desktop b/packaging/linux/org.spotube.spotube.desktop new file mode 100644 index 00000000..6e56f0f6 --- /dev/null +++ b/packaging/linux/org.spotube.spotube.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Spotube +GenericName=Music Player +Comment=Open source, plugin powered music streaming client +Exec=spotube %U +Icon=org.spotube.spotube +Terminal=false +Categories=AudioVideo;Audio;Music;Player; +Keywords=music;streaming;player;audio;lyrics;playlist; +StartupNotify=true +StartupWMClass=org-spotube-spotube-MainKt diff --git a/packaging/linux/org.spotube.spotube.metainfo.xml.mustache b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache new file mode 100644 index 00000000..9519ac88 --- /dev/null +++ b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache @@ -0,0 +1,99 @@ + + + org.spotube.spotube + + + com.github.KRTirtho.Spotube + + + com.github.KRTirtho.Spotube + + + Spotube + Plugin powered, open source music streaming client + + Team Spotube + + + CC0-1.0 + AGPL-3.0-or-later + + +

+ A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. +

+
    +
  • Plugin powered: supports any platform or custom music service through plugins
  • +
  • Community driven plugins for popular platforms, or create your own
  • +
  • Freely downloadable tracks with tagged metadata
  • +
  • Time synced lyrics regardless of plugin support
  • +
  • No telemetry, diagnostics or user data collection
  • +
  • Small size, low data usage and native performance
  • +
+
+ + org.spotube.spotube.desktop + + + AudioVideo + Audio + Music + + + music + streaming + player + lyrics + + + https://spotube.org + https://github.com/team-spotube/spotube/issues + https://github.com/team-spotube/spotube + https://opencollective.com/spotube + https://github.com/team-spotube/spotube/blob/master/CONTRIBUTION.md + + + + Spotube on the desktop + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/branding/spotube-screenshot.png + + + + + #84b2ff + #01185c + + + + + + 360 + + + pointing + keyboard + touch + + + + +{{#releases}} + + {{url}} +{{#notes}} + +{{#groups}} +

{{title}}

+
    +{{#entries}} +
  • {{text}}
  • +{{/entries}} +
+{{/groups}} +
+{{/notes}} +
+{{/releases}} +
+
diff --git a/packaging/package-linux-tarball.sh b/packaging/package-linux-tarball.sh new file mode 100755 index 00000000..9b2c4157 --- /dev/null +++ b/packaging/package-linux-tarball.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Packs the GraalVM native distributable into spotube-linux--.tar.xz, the +# portable tarball the AUR (spotube-bin) and Flathub (org.spotube.spotube) packages install from. +# +# Run after `:composeApp:createGraalvmNativeDistributable` WITHOUT -PappImage=true (the tarball uses the +# system's or the Flatpak runtime's VLC, GTK 3 and WebKitGTK, like the deb/rpm) and +# `:composeApp:renderPackagingLinux` (desktop entry + metainfo with the release notes). +# +# Usage: packaging/package-linux-tarball.sh [stable|nightly] [out-dir] +# +# Layout: +# spotube-linux--/ +# app/ binary + the JDK/Skiko libraries it loads +# share/applications/org.spotube.spotube.desktop +# share/metainfo/org.spotube.spotube.metainfo.xml +# share/icons/hicolor/512x512/apps/org.spotube.spotube.png +# LICENSE +set -euo pipefail + +version="${1:?usage: $0 [stable|nightly] [out-dir]}" +channel="${2:-stable}" +out_dir="${3:-.}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +app_dir="${SPOTUBE_APP_DIR:-$root/composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube}" +linux_dir="$root/composeApp/build/packaging/linux" + +case "$(uname -m)" in + x86_64 | amd64) arch=x86_64 ;; + aarch64 | arm64) arch=aarch64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +if [[ ! -x "$app_dir/spotube" ]]; then + echo "No native distributable at $app_dir; run :composeApp:createGraalvmNativeDistributable first" >&2 + exit 1 +fi +if [[ ! -f "$linux_dir/org.spotube.spotube.metainfo.xml" ]]; then + echo "No rendered metainfo in $linux_dir; run :composeApp:renderPackagingLinux first" >&2 + exit 1 +fi +# An AppImage build (-PappImage=true) leaves VLC plugins and a GTK/WebKit tree in the same dir +if [[ -d "$app_dir/plugins" || -d "$app_dir/usr" ]]; then + echo "$app_dir bundles VLC/GTK (an -PappImage=true build); rebuild without it" >&2 + exit 1 +fi + +name="spotube-linux-$version-$arch" +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +dest="$staging/$name" + +mkdir -p "$dest/share/applications" "$dest/share/metainfo" "$dest/share/icons/hicolor/512x512/apps" +cp -a "$app_dir" "$dest/app" +rm -f "$dest/app/.complete" +install -m644 "$linux_dir/org.spotube.spotube.desktop" "$dest/share/applications/" +install -m644 "$linux_dir/org.spotube.spotube.metainfo.xml" "$dest/share/metainfo/" +install -m644 "$root/composeApp/icons/$channel/icon.png" "$dest/share/icons/hicolor/512x512/apps/org.spotube.spotube.png" +install -m644 "$root/LICENSE" "$dest/LICENSE" + +mkdir -p "$out_dir" +tarball="$(cd "$out_dir" && pwd)/$name.tar.xz" +tar -C "$staging" --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@${SOURCE_DATE_EPOCH:-$(git -C "$root" log -1 --format=%ct)}" \ + -cf - "$name" | xz -T0 -9 > "$tarball" + +echo "$tarball" diff --git a/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache b/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache new file mode 100644 index 00000000..32490e24 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +Platform: +- Windows.Desktop +MinimumOSVersion: 10.0.0.0 +# electron-builder one-click NSIS installer (Nucleus), installs per user; winget's nullsoft default /S is silent +InstallerType: nullsoft +Scope: user +InstallModes: +- silent +- silentWithProgress +UpgradeBehavior: install +ReleaseDate: {{release.date}} +Installers: +- Architecture: x64 + InstallerUrl: {{artifacts.windowsX64.url}} + InstallerSha256: {{artifacts.windowsX64.sha256Upper}} +ManifestType: installer +ManifestVersion: 1.10.0 diff --git a/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache new file mode 100644 index 00000000..fcaae4f9 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +PackageLocale: en-US +Publisher: Team Spotube +PublisherUrl: https://github.com/team-spotube +PublisherSupportUrl: https://github.com/team-spotube/spotube/issues +PrivacyUrl: https://spotube.org/p/privacy +Author: Team Spotube +PackageName: Spotube +PackageUrl: https://spotube.org +License: AGPL-3.0-or-later +LicenseUrl: https://github.com/team-spotube/spotube/blob/master/LICENSE +Copyright: Copyright (c) 2022-2026 Kingkor Roy Tirtho and Spotube Contributors +ShortDescription: Open source, plugin powered music streaming client +Description: |- + A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. +Moniker: spotube +Tags: +- audio +- music +- music-player +- streaming +- lyrics +- plugins +{{#release.notes}} +ReleaseNotes: |- +{{#indent2}}{{release.notes}}{{/indent2}} +{{/release.notes}} +ReleaseNotesUrl: {{release.url}} +ManifestType: defaultLocale +ManifestVersion: 1.10.0 diff --git a/packaging/winget/KRTirtho.Spotube.yaml.mustache b/packaging/winget/KRTirtho.Spotube.yaml.mustache new file mode 100644 index 00000000..1c92b1d4 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.yaml.mustache @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.10.0 From 2edbfefe93ce2f31febe012014913ef497e7bbfc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 10:12:10 +0600 Subject: [PATCH 094/132] fix(gradle): restore VFS watch setting and clean up properties --- gradle.properties | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index dc5eecc2..a49b0977 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,7 +34,4 @@ android.builtInKotlin=false android.newDsl=false android.sourceset.disallowProvider=false -org.gradle.vfs.watch=true - -nightly=true -buildNumber=999 \ No newline at end of file +org.gradle.vfs.watch=true \ No newline at end of file From 09df4b494c81fa8b6741ea14fc41435b6b3c2320 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 11:54:29 +0600 Subject: [PATCH 095/132] build: upgrade to Kotlin 2.4.21 and Compose Multiplatform 1.12.2 Several dependencies (coil 3.5.0, millennium, mqtt/ditchoom, composeunstyled, markdown-renderer, composewebview, kmp-zip, filekit) now ship klibs built with Kotlin 2.4, which Kotlin/Native 2.3 refuses to read, breaking the iOS build. - Bump zipline (1.28.0), mokkery (3.5.1) and ksp (2.3.12) for the new compiler - Type TOP_LEVEL_ROUTES as Set (Kotlin 2.4 inferred Set) - Import ComposeApp in iOSApp.swift, which uses ExternalUriHandler --- .../spotube/spotube/core/navigation/NavigationState.kt | 2 +- gradle/libs.versions.toml | 10 +++++----- iosApp/iosApp/iOSApp.swift | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index f92c91d5..3dc7461e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -40,7 +40,7 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import org.koin.core.component.KoinComponent -val TOP_LEVEL_ROUTES = setOf( +val TOP_LEVEL_ROUTES: Set = setOf( Routes.Home, Routes.Search, Routes.Library, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30e39bf7..30876263 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ appdirs = "1.5.0" cache4k = "0.14.0" carApp = "1.7.0" composeHotReload = "1.2.0" -composeMultiplatform = "1.11.1" +composeMultiplatform = "1.12.2" coil = "3.5.0" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" @@ -37,7 +37,7 @@ jna = "5.18.1" junit = "4.13.2" kermit = "2.1.0" kmpZip = "1.0.0" -kotlin = "2.3.21" +kotlin = "2.4.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" @@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" -ziplineVersion = "1.27.0" +ziplineVersion = "1.28.0" ktor = "3.5.1" dnssdkt = "1.1.0" runner = "1.7.0" @@ -68,7 +68,7 @@ feather-icons = "1.1.1" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" -ksp = "2.3.5" +ksp = "2.3.12" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" @@ -76,7 +76,7 @@ vlcjBundler = "0.2.0" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" -mokkery = "3.3.0" +mokkery = "3.5.1" [libraries] appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index f0058387..349b4ba1 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -14,6 +14,7 @@ // along with this program. If not, see . import SwiftUI +import ComposeApp @main struct iOSApp: App { From ff7a1714ff7dd8538ada6400fc330aae506f3f07 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 11:54:29 +0600 Subject: [PATCH 096/132] refactor(network): central HttpClientFactory; drop Ktor CIO client On iOS the CIO client engine competed with Darwin as the default engine and has no TLS on Kotlin/Native, so HTTPS requests (e.g. plugin discovery) failed. Remove ktor-client-cio and build every client through a Koin-provided HttpClientFactory that owns one client on the platform default engine (Darwin on iOS, OkHttp on Android/desktop). Consumers use the shared default/json clients or derive their own via create { }, sharing the engine. --- AGENTS.md | 6 +- composeApp/build.gradle.kts | 1 - .../org/spotube/spotube/core/di/Modules.kt | 7 ++- .../spotube/core/network/HttpClientFactory.kt | 60 +++++++++++++++++++ .../core/remote/RemoteControlClient.kt | 6 +- .../spotube/core/server/LocalServer.kt | 10 ++-- .../core/tools/user_agents/UserAgents.kt | 19 +++--- .../zipline/host_apis/RealHttpClientAPI.kt | 6 +- .../plugin_apis/lrclib/RealLRCLibLyricsAPI.kt | 35 ++++++----- .../plugin_apis/newpipe_yt/ISRCProviders.kt | 15 ++--- .../modules/downloads/DownloadManager.kt | 7 ++- .../modules/plugin/GitHubPluginRepo.kt | 15 +---- .../spotube/modules/plugin/PluginManager.kt | 7 ++- .../spotube/modules/plugin/PluginViewModel.kt | 10 ++-- .../modules/update/AppUpdateService.kt | 18 ++---- 15 files changed, 133 insertions(+), 89 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt diff --git a/AGENTS.md b/AGENTS.md index e61c7d52..6fdadf2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,13 @@ ## Dependencies - `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. -- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). +- Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). - **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). +## Networking +- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). +- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails. + ## GraalVM native image (desktop) - Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. - Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b717523d..70c93430 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -199,7 +199,6 @@ kotlin { implementation(libs.ktor.client.logging) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) - implementation(libs.ktor.client.cio) implementation(libs.ktor.client.websockets) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index 05ec4253..d7668946 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -17,6 +17,8 @@ package org.spotube.spotube.core.di +import org.koin.core.module.dsl.onClose +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository @@ -108,6 +110,7 @@ expect val platformModules: Module val sharedModules = module { includes(navigationModule) singleOf(::Database) + single { HttpClientFactory() } withOptions { onClose { it?.close() } } singleOf(::WebViewController) @@ -164,7 +167,7 @@ val sharedModules = module { viewModelOf(::WelcomeViewModel) // Updates - single { AppUpdateService(get()) } withOptions { createdAtStart() } + single { AppUpdateService(get(), get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } // Downloads @@ -273,7 +276,7 @@ val sharedModules = module { createdAtStart() } single { RemoteControlHandler(get(), get(), get(), get()) } - single { RemoteControlClient() } + single { RemoteControlClient(get()) } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt new file mode 100644 index 00000000..1157bec2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt @@ -0,0 +1,60 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +/** + * The single place the app builds Ktor [HttpClient]s. + * + * It owns one client on the platform's default engine (Darwin on iOS, OkHttp on + * Android/desktop), so every client shares that engine's connection pool and + * no code path can accidentally pick an engine that doesn't work on a platform. + * + * - [default] and [json] are shared: use them directly and never close them. + * - [create] derives a client with extra configuration (timeouts, WebSockets, + * default request, …). It reuses the shared engine; the caller owns the + * returned client and may close it without affecting anyone else. + */ +class HttpClientFactory : AutoCloseable { + private val root = HttpClient() + + /** Plain client with no plugins installed. */ + val default: HttpClient get() = root + + /** Client that (de)serializes JSON bodies via [HttpJson]. */ + val json: HttpClient by lazy { + create { install(ContentNegotiation) { json(HttpJson) } } + } + + fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block) + + override fun close() { + root.close() + } +} + +/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */ +val HttpJson = Json { + ignoreUnknownKeys = true + isLenient = true +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt index ed7dce2e..f6f474c3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt @@ -17,8 +17,8 @@ package org.spotube.spotube.core.remote +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.header import io.ktor.client.request.url @@ -49,7 +49,7 @@ import spotube.composeapp.generated.resources.devices_error_send_failed * WebSocket client for controlling a remote Spotube instance. * Connects to the remote device's `/control` endpoint and sends commands. */ -class RemoteControlClient { +class RemoteControlClient(httpClientFactory: HttpClientFactory) { private val logger = Logger.withTag("RemoteControlClient") private val json = Json { ignoreUnknownKeys = true @@ -59,7 +59,7 @@ class RemoteControlClient { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val httpClient = HttpClient { + private val httpClient = httpClientFactory.create { install(WebSockets) install(HttpTimeout) { connectTimeoutMillis = 10_000 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index d0a700e9..14e4837c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -17,16 +17,15 @@ package org.spotube.spotube.core.server +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.remote.RemoteControlHandler import org.spotube.spotube.modules.settings.SettingsViewModel -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application import io.ktor.server.application.install -import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer import io.ktor.server.response.respondText @@ -60,11 +59,12 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, - private val remoteControlHandler: RemoteControlHandler) : KoinComponent { + private val remoteControlHandler: RemoteControlHandler, + httpClientFactory: HttpClientFactory) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient { - // A stalled upstream connection must not wedge the CIO dispatcher thread + private val httpClient = httpClientFactory.create { + // A stalled upstream connection must not wedge an engine thread // forever. Only the connect phase is bounded — the proxy streams long // audio bodies, so request/socket timeouts would cut playback short. install(HttpTimeout) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt index 28a0674b..de319170 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt @@ -17,12 +17,13 @@ package org.spotube.spotube.core.tools.user_agents +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.PlatformType import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.getPlatform -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess @@ -53,7 +54,8 @@ import kotlin.time.Clock * [random] is intentionally synchronous and never blocks on the network: it serves * whatever is cached (even stale) and refreshes in the background when needed. */ -object UserAgents { +object UserAgents : KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() private const val SOURCE_URL = "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" private const val CACHE_FILE_NAME = "user_agents_cache.json" @@ -111,14 +113,9 @@ object UserAgents { private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/") private suspend fun fetchRemote(): List { - val client = HttpClient(CIO) - try { - val res = client.get(SOURCE_URL) - if (!res.status.isSuccess()) error("Unexpected status ${res.status}") - return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) - } finally { - client.close() - } + val res = httpClientFactory.default.get(SOURCE_URL) + if (!res.status.isSuccess()) error("Unexpected status ${res.status}") + return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) } private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt index a396aced..553966b1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -17,11 +17,12 @@ package org.spotube.spotube.core.zipline.host_apis +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.host_apis.HttpClientAPI import org.spotube.plugin_interfaces.host_apis.HttpMethod import org.spotube.plugin_interfaces.host_apis.HttpResponse import org.spotube.spotube.core.di.injectLogger -import io.ktor.client.HttpClient import io.ktor.client.request.request import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText @@ -30,7 +31,8 @@ import org.koin.core.component.KoinComponent class RealHttpClientAPI : HttpClientAPI, KoinComponent { val logger by injectLogger() - val httpClient = HttpClient() + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient get() = httpClientFactory.default override suspend fun request( method: HttpMethod, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt index b46bc9e0..59a931b8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -17,11 +17,14 @@ package org.spotube.spotube.core.zipline.plugin_apis.lrclib +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpJson +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class LRCLibResponse( @@ -47,19 +49,22 @@ data class LRCLibResponse( val plainLyrics: String?, val syncedLyrics: String?) -class RealLRCLibLyricsAPI : LyricsAPI { - private val httpClient = HttpClient { - install(Logging) { - level = LogLevel.ALL - logger = Logger.SIMPLE - } - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - defaultRequest { - url("https://lrclib.net/api/get") - // TODO: Add the actual version of Spotube instead of hardcoding it - userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") +class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient by lazy { + httpClientFactory.create { + install(Logging) { + level = LogLevel.ALL + logger = Logger.SIMPLE + } + install(ContentNegotiation) { + json(HttpJson) + } + defaultRequest { + url("https://lrclib.net/api/get") + // TODO: Add the actual version of Spotube instead of hardcoding it + userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index a1c80553..52b2eaa7 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -17,15 +17,15 @@ package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.tools.user_agents.UserAgents -import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post @@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent { .lowercase() } - val client = HttpClient() { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - } + private val httpClientFactory: HttpClientFactory by inject() + private val client get() = httpClientFactory.json private fun MetadataTrack.describe() = "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index 9b1824a0..2cbd6775 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.downloads +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.paths.Paths @@ -26,7 +27,6 @@ import org.spotube.spotube.core.server.StreamingUrlRepository import org.spotube.spotube.core.server.urlSummary import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.spotube.modules.settings.SettingsRepository -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import io.ktor.client.request.head @@ -88,7 +88,8 @@ sealed interface DownloadStatus { class DownloadManager( private val settingsRepository: SettingsRepository, private val paths: Paths, - private val streamingUrlRepository: StreamingUrlRepository + private val streamingUrlRepository: StreamingUrlRepository, + private val httpClientFactory: HttpClientFactory ) { companion object { const val MAX_CONCURRENT_DOWNLOADS = 4 @@ -101,7 +102,7 @@ class DownloadManager( private val fileSystem = FileSystem.SYSTEM private val queueMutex = Mutex() - private val httpClient = HttpClient { + private val httpClient = httpClientFactory.create { install(HttpTimeout) { requestTimeoutMillis = 300_000 connectTimeoutMillis = 30_000 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt index 34d1fd92..86a8f30b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -17,9 +17,9 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.headers import io.ktor.client.request.parameter @@ -27,7 +27,6 @@ import io.ktor.http.append import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class GitHubRepoSearchResponse( @@ -64,12 +63,8 @@ data class GitHubAsset( val name: String, @SerialName("browser_download_url") val browserDownloadUrl: String) -class GitHubPluginRepository { - private val httpClient = HttpClient { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } +/** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */ +class GitHubPluginRepository(private val httpClient: HttpClient) { suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse { return httpClient.get("https://api.github.com/search/repositories") { @@ -113,8 +108,4 @@ class GitHubPluginRepository { emptyList() } } - - fun close() { - httpClient.close() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 71871b23..6b2ed87a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.spotube.core.db.Database import org.spotube.spotube.core.db.DatabaseKeys import org.spotube.spotube.core.di.injectLogger @@ -24,7 +25,6 @@ import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.zipline.BuiltInPluginService import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.ZiplinePluginService -import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.bodyAsChannel import io.ktor.utils.io.readRemaining @@ -113,7 +113,8 @@ interface ScrobblePluginSource { class PluginManager( val database: Database, - val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { + val paths: Paths, + private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -121,7 +122,7 @@ class PluginManager( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() val pluginsDirPath: Path get() = pluginsDir - private val httpClient = HttpClient() + private val httpClient get() = httpClientFactory.default enum class InstallPromptKind { INSTALL, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt index e3d5ff5c..7d685cfd 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.spotube.spotube.core.di.injectLogger @@ -141,10 +142,11 @@ private data class PluginScreenState( @OptIn(ExperimentalCoroutinesApi::class) class PluginViewModel( private val pluginManager: PluginManager, - private val webViewController: WebViewController) : ViewModel(), KoinComponent { + private val webViewController: WebViewController, + httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent { private val logger by injectLogger() - private val gitHubRepo = GitHubPluginRepository() + private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json) private val _screenState = MutableStateFlow(PluginScreenState()) private val _allRepos = mutableListOf() @@ -658,8 +660,4 @@ class PluginViewModel( } } - override fun onCleared() { - gitHubRepo.close() - super.onCleared() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt index 234a0338..0bbc8552 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt @@ -17,13 +17,11 @@ package org.spotube.spotube.modules.update +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.AppBuildInfo import org.spotube.spotube.modules.settings.SettingsRepository -import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.http.HttpHeaders @@ -37,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import net.swiftzer.semver.SemVer import org.koin.core.component.KoinComponent @@ -52,14 +49,11 @@ data class AvailableAppUpdate( * process, independent of any screen's lifecycle. */ class AppUpdateService( - private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable { + private val settingsRepository: SettingsRepository, + httpClientFactory: HttpClientFactory) : KoinComponent { private val logger = Logger.withTag("AppUpdateService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val httpClient = HttpClient(CIO) { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } + private val httpClient = httpClientFactory.json private val _availableUpdate = MutableStateFlow(null) val availableUpdate: StateFlow = _availableUpdate.asStateFlow() @@ -117,10 +111,6 @@ class AppUpdateService( private fun parseVersion(version: String): SemVer? = runCatching { SemVer.parse(version) }.getOrNull() - - override fun close() { - httpClient.close() - } } @Serializable From 7eb56c85931d4c0ad7cca09f948401baebdfc472 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 11:54:29 +0600 Subject: [PATCH 097/132] fix(ios): run Zipline on an 8 MiB stack thread newSingleThreadContext threads get only 512 KiB of stack on iOS, while QuickJS may recurse ~6 MiB deep when compiling a plugin, so the app crashed with SIGBUS at startup once a plugin was installed. Host the dispatcher on an NSThread with an 8 MiB stack, matching JVM/Android. --- .../spotube/core/zipline/ZiplineDispatcher.kt | 3 +- .../core/zipline/ZiplineDispatcher.ios.kt | 35 +++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt index 8ee39b8f..99496217 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt @@ -40,7 +40,8 @@ class ZiplineDispatcher( * size is ~1 MiB, which causes native stack overflow when compiling large * JS modules like kotlin-stdlib (~491 KB). * - * This expect/actual ensures the backing thread has a sufficiently large stack. + * iOS secondary threads get only 512 KiB, so every platform must create its own + * large-stack thread. This expect/actual ensures the backing thread has one. */ expect fun createZiplineDispatcher(): ZiplineDispatcher diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt index 8249ae1e..c4a1559a 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt @@ -17,17 +17,32 @@ package org.spotube.spotube.core.zipline -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import platform.Foundation.NSThread +import kotlin.coroutines.ContinuationInterceptor + +// Same as JVM/Android. Zipline lets QuickJS recurse up to ~6 MiB of native stack, but +// iOS gives secondary threads (incl. newSingleThreadContext's) only 512 KiB, so loading +// a plugin overflowed into the guard page and crashed the app with SIGBUS. +private const val ZIPLINE_THREAD_STACK_SIZE: ULong = 8_388_608u // 8 MiB -@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) actual fun createZiplineDispatcher(): ZiplineDispatcher { - // On iOS/native, thread stack sizes are typically 8 MiB by default, - // so newSingleThreadContext is sufficient. - val ctx = newSingleThreadContext("Zipline") - return ZiplineDispatcher(ctx) { - ctx.close() + val dispatcher = CompletableDeferred() + val stopped = CompletableDeferred() + // NSThread is the only way to choose a thread's stack size here; it hosts an event + // loop (runBlocking) whose dispatcher accepts work from any thread. + val thread = NSThread { + runBlocking { + dispatcher.complete(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher) + stopped.await() + } + } + thread.name = "Zipline" + thread.stackSize = ZIPLINE_THREAD_STACK_SIZE + thread.start() + return ZiplineDispatcher(runBlocking { dispatcher.await() }) { + stopped.complete(Unit) } } - From 0acc457890edfeee3207651cd65cd752b508940e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 12:44:04 +0600 Subject: [PATCH 098/132] feat(plugin): implement automatic plugin updates and enhance JSON parsing --- .../kotlin/org/spotube/spotube/App.kt | 5 +-- .../spotube/modules/plugin/PluginManager.kt | 16 ++++++- .../spotube/modules/plugin/PluginModels.kt | 43 +++++++++++++++++++ .../modules/update/PluginUpdateDialog.kt | 6 ++- .../modules/update/PluginUpdateService.kt | 12 ++++++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt index 9655a6d3..37a15a6f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt @@ -262,10 +262,7 @@ fun App( update = update, onDismiss = pluginUpdateService::dismissUpdate, onIgnore = pluginUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser(update.info.directDownloadUrl) } - pluginUpdateService.dismissUpdate() - }) + onUpdate = pluginUpdateService::installUpdate) } SupportDialog( visible = showSupportDialog, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 71871b23..c9d673e2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -115,6 +115,9 @@ class PluginManager( val database: Database, val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() + + // plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`). + private val pluginJsonParser = Json { ignoreUnknownKeys = true } private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } } @@ -324,6 +327,15 @@ class PluginManager( } } + /** Downloads and installs a newer plugin build over the installed one, without prompting. */ + suspend fun updatePluginFromURL(url: String) { + withContext(Dispatchers.IO) { + val response = httpClient.get(url) + val bytes = response.bodyAsChannel().readRemaining().readByteArray() + addPluginFromByteArray(bytes = bytes, allowReplacingInstalled = true) + } + } + /** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */ suspend fun preparePlugin(bytes: ByteArray) { withContext(Dispatchers.IO) { @@ -362,7 +374,7 @@ class PluginManager( val pluginJson = FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } @@ -484,7 +496,7 @@ class PluginManager( } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt index 2048248f..050dd859 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt @@ -18,9 +18,18 @@ package org.spotube.spotube.modules.plugin import com.goncalossilva.murmurhash.MurmurHash3 +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +@Serializable enum class PluginCapability { @SerialName("persistent_storage") PERSISTENT_STORAGE, @@ -43,6 +52,38 @@ enum class PluginAbility { SCROBBLE, } +/** + * Decodes a list of enums while dropping values this app version doesn't know about, so a + * plugin.json written for another plugin API revision still parses. + */ +abstract class LenientEnumListSerializer>( + private val elementSerializer: KSerializer) : KSerializer> { + private val listSerializer = ListSerializer(elementSerializer) + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) = + listSerializer.serialize(encoder, value) + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder ?: return listSerializer.deserialize(decoder) + val array = jsonDecoder.decodeJsonElement() as? JsonArray + ?: throw SerializationException("Expected a JSON array") + return array.mapNotNull { element -> + try { + jsonDecoder.json.decodeFromJsonElement(elementSerializer, element) + } catch (_: SerializationException) { + null + } + } + } +} + +object LenientPluginCapabilityListSerializer : + LenientEnumListSerializer(PluginCapability.serializer()) + +object LenientPluginAbilityListSerializer : + LenientEnumListSerializer(PluginAbility.serializer()) + @Serializable data class PluginEntry( val name: String, @@ -50,7 +91,9 @@ data class PluginEntry( val apiVersion: String, val description: String, val author: String, + @Serializable(with = LenientPluginCapabilityListSerializer::class) val capabilities: List, + @Serializable(with = LenientPluginAbilityListSerializer::class) val abilities: List, val contact: String, val repository: String, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt index dc9d3388..dbfd236f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt @@ -20,7 +20,10 @@ package org.spotube.spotube.modules.update import org.spotube.millennium.composables.MillenniumAlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.text.selection.SelectionContainer import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text @@ -40,7 +43,7 @@ import spotube.composeapp.generated.resources.update_action_update /** * Prompts the user that a newer version of one of their installed plugins is - * available. [onUpdate] should open the plugin's direct download URL; [onIgnore] + * available. [onUpdate] should download and install the update; [onIgnore] * persistently silences this specific plugin version so the check moves on to the * next newer one (or another plugin). */ @@ -53,6 +56,7 @@ fun PluginUpdateDialog( MillenniumAlertDialog( visible = true, onDismissRequest = onDismiss, + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing), title = { Text( stringResource( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt index 0b941cde..6c09dbeb 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt @@ -124,6 +124,18 @@ class PluginUpdateService( } } + fun installUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + pluginManager.updatePluginFromURL(update.info.directDownloadUrl) + }.onFailure { e -> + logger.e(e) { "Failed to install update for plugin ${update.pluginName}" } + } + } + } + fun dismissUpdate() { _availableUpdate.value = null } From 99ba08501bf656ba80f639c3dfd31cc14f08d18e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 13:40:00 +0600 Subject: [PATCH 099/132] fix(server): don't crash when the proxy port is still in use CIO reports a failed bind from its accept coroutine; unhandled, that exception terminates the process on Kotlin/Native before the port fallback can run, so relaunching iOS shortly after playback crashed (EADDRINUSE). Contain it with a parent CoroutineExceptionHandler and enable reuseAddress so the preferred port can be rebound while old connections are in TIME_WAIT. --- .../spotube/core/server/ServerBinder.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt index 756cc73e..43d44309 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt @@ -21,7 +21,10 @@ package org.spotube.spotube.core.server import io.ktor.server.application.Application import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.connector import io.ktor.server.engine.embeddedServer +import io.ktor.server.application.serverConfig +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withTimeout @@ -37,6 +40,9 @@ internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val * preferred port can't be bound, retry on an OS-assigned free port (port 0). * * The caller must read [BoundServer.port] instead of assuming [preferredPort]. + * + * `reuseAddress` lets a restarted app rebind its old port while connections from the previous + * run (e.g. the player's) are still in TIME_WAIT. */ internal suspend fun startServerWithPortFallback( host: String, @@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback( val candidates = listOf(preferredPort, 0).distinct() var lastFailure: Throwable? = null + // CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the + // process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still + // reaches us through resolvedConnectors(), so it only needs to be contained here. + val swallowBindFailure = CoroutineExceptionHandler { _, _ -> } + for (candidate in candidates) { - val engine = embeddedServer(factory = CIO, host = host, port = candidate, module = module) + val engine = embeddedServer( + factory = CIO, + rootConfig = serverConfig { + parentCoroutineContext = swallowBindFailure + module { module() } + }, + configure = { + connector { + this.host = host + this.port = candidate + } + reuseAddress = true + }) try { engine.start(wait = false) val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } From cb8316d1417fe0f3f536dfadea055e05e39baa7a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 13:40:00 +0600 Subject: [PATCH 100/132] fix(ios): stream formats AVPlayer can play AVPlayer can't open WebM/Ogg or DASH, so the default opus/webm stream failed silently on iOS. - Rank streams the platform's player can play above format/quality prefs - Default iOS streaming to aac/mp4, migrate saved WebM/Ogg prefs on iOS and hide unplayable formats in the picker (desktop/Android keep opus/webm) - Normalize codec/container names on iOS like androidJvm (mp4a -> aac, m4a -> mp4) - Wait up to 30s for the audio plugin to load when resolving, so the queue restored at startup isn't failed permanently before plugins are ready --- .../core/newpipe/NewPipeService.androidJvm.kt | 8 --- .../spotube/core/newpipe/NewPipeModels.kt | 12 ++++- .../core/server/StreamingUrlRepository.kt | 52 ++++++++++++++++-- .../modules/settings/SettingsModels.kt | 34 ++++++++---- .../modules/settings/SettingsRepository.kt | 4 +- .../modules/settings/sections/utils.kt | 6 ++- .../StreamingUrlRepositoryBehaviorTest.kt | 18 +++++++ .../core/server/StreamingUrlRepositoryTest.kt | 35 +++++++++++- .../settings/StreamingFormatDefaultsTest.kt | 53 +++++++++++++++++++ .../core/newpipe/NewPipeService.ios.kt | 4 +- 10 files changed, 198 insertions(+), 28 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index a79f5a03..1500c6f9 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -162,14 +162,6 @@ actual class NewPipeService { * "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded * aac/mp4 DASH/HLS manifests always won instead. */ -private fun normalizeCodec(codec: String?): String { - val value = codec.orEmpty() - return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value -} - -private fun normalizeContainer(suffix: String): String = - if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix - private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) { DeliveryMethod.DASH -> StreamProtocol.DASH DeliveryMethod.HLS -> StreamProtocol.HLS diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt index 8ca9471f..91fe54a2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt @@ -49,4 +49,14 @@ data class MediaStream( val container: String, val bitrate: Int, val isAudio: Boolean, - val isVideo: Boolean) \ No newline at end of file + val isVideo: Boolean) + +/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */ +internal fun normalizeCodec(codec: String?): String { + val value = codec.orEmpty() + return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value +} + +/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */ +internal fun normalizeContainer(suffix: String): String = + if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 3ee50fa0..19692bf6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -23,13 +23,18 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality 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.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.getPlatform import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.component.KoinComponent import kotlin.math.abs import kotlin.time.Clock @@ -111,7 +116,14 @@ class StreamingUrlRepository( // read it, nor replace it with a stream playback didn't ask for. val useUrlCache = request == null + // A built-in audio plugin is always selected, so null means plugins are still loading. + // The player requests the restored queue's track right at startup; failing that request + // leaves the item permanently failed (AVPlayer), so wait for the plugin instead. val audioPlugin = pluginManager.selectedAudioPlugin.value + ?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) { + logger.i { "[Resolve $trackId] waiting for the audio plugin to load" } + pluginManager.selectedAudioPlugin.filterNotNull().first() + } if (audioPlugin == null) { logger.w { "No audio plugin selected while resolving stream for track $trackId" } return null @@ -243,10 +255,12 @@ class StreamingUrlRepository( logger.i { "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + - "(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" + + "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + + "lowest wins):\n" + streams.joinToString("\n") { stream -> val marker = if (stream === selected) " -> " else " " - "$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 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" }}, " + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" @@ -254,6 +268,10 @@ 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.describeForLog()}, which the player will likely reject" + } formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + "got ${selected.codec}/${selected.container} (${selected.protocol})" @@ -371,6 +389,8 @@ class StreamingUrlRepository( * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are * resolved against the YouTube origin; progressive URLs are returned unchanged. */ +private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds + internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { if (url.startsWith("http")) return url return when (protocol) { @@ -379,12 +399,21 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String } } -/** Selects the available stream closest to the user's preferred format and quality. */ +internal val currentPlatform: PlatformType by lazy { getPlatform().type } + +/** + * Selects the available stream closest to the user's preferred format and quality. + * Streams the [platform]'s player can decode always win over ones it can't, whatever the + * preferences say: e.g. opus/webm is unplayable on iOS. + */ internal fun selectPreferredAudioStream( streams: List, preferredFormat: AudioFormat, - preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( + preferredQuality: AudioQuality, + platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull( compareBy { stream -> + if (stream.isPlayableOn(platform)) 0 else 1 + }.thenBy { stream -> formatMismatchCount(stream, preferredFormat) }.thenBy { stream -> if (stream.matchesQualityType(preferredQuality)) 0 else 1 @@ -395,6 +424,21 @@ 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") + +/** + * 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. + */ +internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = + isContainerPlayableOn(container, platform) && + !(platform == PlatformType.IOS && protocol == StreamProtocol.DASH) + +/** 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 + private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index d6da4e16..8d0fa86d 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -17,6 +17,9 @@ package org.spotube.spotube.modules.settings +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform +import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import kotlinx.serialization.SerialName @@ -42,15 +45,7 @@ data class UserSettings( val palette: MillenniumPalette = MillenniumPalette.Blue, // Playback - val streamingMusicFormat: AudioFormat = AudioFormat( - codec = "opus", - container = "webm", - qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000)) - ), + val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(), val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), val enableMusicCaching: Boolean = true, val cacheFolder: String? = null, @@ -111,3 +106,24 @@ data class JamBroker( val clientIdPrefix: String = "spotube", val keepAliveSeconds: Int = 30, val connectionTimeoutSeconds: Int = 10) + +/** + * opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, 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 + return AudioFormat( + codec = if (ios) "aac" else "opus", + container = if (ios) "mp4" else "webm", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000))) +} + +/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */ +internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings = + if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this + else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt index a69fd159..b8b3772c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -54,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { - settingsJson.decodeFromString(json as String) + settingsJson.decodeFromString(json as String).withPlayableStreamingFormat() } else { UserSettings() // Default value } @@ -73,6 +73,6 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { suspend fun getCurrentSettings(): UserSettings { val preferences = database.settingsDataStore.data.first() val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() - return settingsJson.decodeFromString(storedJson) + return settingsJson.decodeFromString(storedJson).withPlayableStreamingFormat() } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt index ab59ce60..34849447 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt @@ -18,6 +18,8 @@ package org.spotube.spotube.modules.settings.sections +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.theme.MillenniumTheme @@ -56,6 +58,7 @@ private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 256_000)) +// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg). internal val streamingFormatPresets = listOf( AudioFormat( codec = "opus", @@ -68,7 +71,8 @@ internal val streamingFormatPresets = listOf( AudioFormat( codec = "vorbis", container = "ogg", - qualities = standardLossyQualities)) + qualities = standardLossyQualities) +).filter { isContainerPlayableOn(it.container, currentPlatform) } internal val downloadFormatPresets = listOf( AudioFormat( diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index d7bda94a..f297b17a 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -8,6 +8,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.modules.settings.UserSettings import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -130,6 +132,22 @@ class StreamingUrlRepositoryBehaviorTest { assertNull(info) } + @Test + fun `waits for an audio plugin that is still loading`() = runTest { + val h = Harness() + val loader = launch { + delay(5_000) + h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + } + + val info = h.repository.resolveStreamInfo(track("t1")) + loader.join() + + assertEquals("https://example.com/a.opus", info?.url) + } + @Test fun `returns null when the plugin yields no sources`() = runTest { val h = Harness() diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt index e950747a..b5f68eb9 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -1,5 +1,6 @@ package org.spotube.spotube.core.server +import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream @@ -13,6 +14,7 @@ import kotlin.test.assertSame * Tests for the stream preference selection used by [StreamingUrlRepository]. * * Ordering contract (best first): + * 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH). * 1. Fewest codec/container mismatches against the preferred format. * 2. Matching quality family (lossy vs lossless). * 3. Closest bitrate / sample rate. @@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest { private fun select( streams: List, format: AudioFormat = opusWebm(), - quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality) + quality: AudioQuality = AudioQuality.Lossy(256_000), + platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform) // ---------- empty / single ---------- @@ -403,4 +406,34 @@ class StreamingUrlRepositoryTest { assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) } + + // ---------- device playability ---------- + + @Test + fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS dash manifests are skipped in favour of progressive streams`() { + val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH) + val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS an unplayable stream is still returned when nothing else exists`() { + val opus = lossy("opus-160", 160_000) + assertSame(opus, select(listOf(opus), platform = PlatformType.IOS)) + } + + @Test + fun `off iOS the preferred opus webm stream still wins`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android)) + } + } diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt new file mode 100644 index 00000000..cc35c849 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -0,0 +1,53 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.spotube.PlatformType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class StreamingFormatDefaultsTest { + + private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + @Test + fun `ios defaults to aac in mp4`() { + val format = defaultStreamingMusicFormat(PlatformType.IOS) + assertEquals("aac" to "mp4", format.codec to format.container) + } + + @Test + fun `other platforms keep opus in webm`() { + val format = defaultStreamingMusicFormat(PlatformType.Android) + assertEquals("opus" to "webm", format.codec to format.container) + } + + @Test + fun `saved opus webm is moved to aac on ios`() { + val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS) + assertEquals("mp4", migrated.streamingMusicFormat.container) + } + + @Test + fun `saved opus webm is kept off ios`() { + val settings = UserSettings(streamingMusicFormat = opusWebm) + assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux)) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index f70b3a39..95eb42c1 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -77,9 +77,9 @@ actual class NewPipeService { .map { stream -> org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( url = stream.content, - codec = stream.codec ?: "unknown", + codec = normalizeCodec(stream.codec ?: "unknown"), bitrate = stream.bitrate, - container = stream.format?.suffix ?: "unknown") + container = normalizeContainer(stream.format?.suffix ?: "unknown")) }, videoStreams = listOf(), // Not yet supported id = id From cc21568457c44dd02bfafeb39adb76f5568726d4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 15:04:26 +0600 Subject: [PATCH 101/132] fix(build): restore version catalog lost in dev merge The merge of dev resolved the libs.versions.toml conflict by taking the wrong catalog: it reverted the Kotlin 2.4.21 / Compose 1.12.2 / Zipline 1.28.0 upgrade and dropped dev's jmustache entries, so buildSrc failed with "Unresolved reference 'jmustache'". Keep both sides: mokkery 3.5.1 plus jmustache. --- gradle/libs.versions.toml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30e39bf7..838df455 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ appdirs = "1.5.0" cache4k = "0.14.0" carApp = "1.7.0" composeHotReload = "1.2.0" -composeMultiplatform = "1.11.1" +composeMultiplatform = "1.12.2" coil = "3.5.0" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" @@ -37,7 +37,7 @@ jna = "5.18.1" junit = "4.13.2" kermit = "2.1.0" kmpZip = "1.0.0" -kotlin = "2.3.21" +kotlin = "2.4.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" @@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" -ziplineVersion = "1.27.0" +ziplineVersion = "1.28.0" ktor = "3.5.1" dnssdkt = "1.1.0" runner = "1.7.0" @@ -68,7 +68,7 @@ feather-icons = "1.1.1" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" -ksp = "2.3.5" +ksp = "2.3.12" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" @@ -76,9 +76,12 @@ vlcjBundler = "0.2.0" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" -mokkery = "3.3.0" +mokkery = "3.5.1" +jmustache = "1.16" [libraries] +# buildSrc: renders the package manager manifests in packaging/ +jmustache = { module = "com.samskivert:jmustache", version.ref = "jmustache" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } From bdf3655a23c6da8bb79ce471b4ca7add7cee7a3a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 15:04:26 +0600 Subject: [PATCH 102/132] fix(ios): make the login WebView identify as Mobile Safari accounts.spotify.com rejected iOS logins ("Oops, something went wrong") because the WebView sent WKWebView's default User-Agent, which lacks Safari's "Version/x ... Safari/604.1" and reads as an embedded browser. The random User-Agent header never reached any request: the screen replaces the content before it loads. Have WebKit build Safari's exact UA (with its frozen OS version) via a probe WKWebView using Safari's applicationNameForUserAgent, prime it at startup and apply it through webSettings.customUserAgentString so every request uses it. --- .../org/spotube/spotube/MainViewController.kt | 2 + .../core/webview/PlatformWebviewConfig.ios.kt | 82 ++++++++++--------- .../spotube/core/webview/SafariUserAgent.kt | 68 +++++++++++++++ 3 files changed, 112 insertions(+), 40 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt index 1e72f984..e3b722f6 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.window.ComposeUIViewController import com.yushosei.newpipe.extractor.NewPipe import com.yushosei.newpipe.util.DefaultDownloaderImpl import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.webview.SafariUserAgent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -35,6 +36,7 @@ import org.koin.core.component.inject fun MainViewController() = ComposeUIViewController(configure = { initKoin() + SafariUserAgent.prime() }) { InitNewPipe { App() } } @Composable diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt index 9b1ed717..c465732c 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt @@ -1,41 +1,43 @@ -/* - * 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 . - */ - -package org.spotube.spotube.core.webview - -import dev.nucleusframework.webview.web.WebViewState -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSDate -import platform.Foundation.NSHTTPCookieStorage -import platform.Foundation.distantPast -import platform.WebKit.WKWebsiteDataStore -import platform.WebKit.WKWebsiteDataTypeCookies -import platform.WebKit.WKWebsiteDataTypeLocalStorage - -actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { -} - -@OptIn(ExperimentalForeignApi::class) -actual suspend fun platformClearWebviewData(pluginId: String?) { - if (pluginId == null) return - - val dataStore = WKWebsiteDataStore.defaultDataStore() - val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage) - dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {} - - NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast) +/* + * 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 . + */ + +package org.spotube.spotube.core.webview + +import dev.nucleusframework.webview.web.WebViewState +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +import platform.Foundation.NSHTTPCookieStorage +import platform.Foundation.distantPast +import platform.WebKit.WKWebsiteDataStore +import platform.WebKit.WKWebsiteDataTypeCookies +import platform.WebKit.WKWebsiteDataTypeLocalStorage + +actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { + // Applied to the WKWebView itself, so it covers redirects and XHR too, not just the first request. + SafariUserAgent.value?.let { webView.webSettings.customUserAgentString = it } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun platformClearWebviewData(pluginId: String?) { + if (pluginId == null) return + + val dataStore = WKWebsiteDataStore.defaultDataStore() + val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage) + dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {} + + NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast) } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt new file mode 100644 index 00000000..4607265d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt @@ -0,0 +1,68 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.webview + +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIDevice +import platform.UIKit.UIUserInterfaceIdiomPad +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration + +/** + * Mobile Safari's exact User-Agent on this device. + * + * WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`, + * which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went + * wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26 + * reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application + * name and reports `navigator.userAgent`, which is cached for every web view. + */ +internal object SafariUserAgent { + var value: String? = null + private set + + // Kept alive until its async evaluation calls back. + private var probe: WKWebView? = null + + /** Must run on the main thread (WebKit); call once at startup. */ + @OptIn(ExperimentalForeignApi::class) + fun prime() { + if (value != null || probe != null) return + val config = WKWebViewConfiguration().apply { applicationNameForUserAgent = safariApplicationName() } + probe = WKWebView(frame = CGRectZero.readValue(), configuration = config).also { webView -> + webView.evaluateJavaScript("navigator.userAgent") { result, _ -> + value = result as? String + probe = null + Logger.withTag("SafariUserAgent").i { "WebView User-Agent: $value" } + } + } + } + + // What Safari appends after WebKit's base UA; iPad Safari sends the desktop-class form. + private fun safariApplicationName(): String { + val version = UIDevice.currentDevice.systemVersion.split('.').take(2).joinToString(".") + return if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) { + "Version/$version Safari/605.1.15" + } else { + "Version/$version Mobile/15E148 Safari/604.1" + } + } +} From 1ea5f1d1ee8dde6b97b6542ec7ea1aa81f7189e2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 15:31:17 +0600 Subject: [PATCH 103/132] fix: update website URLs from spotube.org to spotube.cc --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- README.md | 6 +++--- composeApp/build.gradle.kts | 2 +- composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt | 2 +- packaging/aur/PKGBUILD.mustache | 2 +- packaging/chocolatey/spotube.nuspec.mustache | 4 ++-- packaging/linux/org.spotube.spotube.metainfo.xml.mustache | 2 +- .../winget/KRTirtho.Spotube.locale.en-US.yaml.mustache | 4 ++-- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8a31ee59..4b62e4f8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,7 +76,7 @@ body: description: Where did you install Spotube from? multiple: true options: - - "Website (spotube.org)" + - "Website (spotube.cc)" - "GitHub Releases (Binary)" - "GitHub Actions (Nightly Binary)" - "Play Store (Android)" diff --git a/README.md b/README.md index 86b305fc..833b6e2c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A cross-platform extensible open-source music streaming platform.
Bring your own music metadata/playlist/audio-source with plugins created by community or by yourself. A small step towards the decentralized music streaming era! -Visit the website +Visit the website Discord Server Support me on Patron @@ -37,11 +37,11 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm ### ⬇️ Download -Download Spotube from the Official Website: https://spotube.org +Download Spotube from the Official Website: https://spotube.cc ### 🔄 Nightly Builds -Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.org/downloads/nightly). +Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). ## 🕳️ Building from source diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index fb121190..571aa28d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -68,7 +68,7 @@ val isProductionBuild = providers.gradleProperty("production").map { it.toBoolea } ) val targetName = "Spotube" -val homePageUrl = "https://spotube.org" +val homePageUrl = "https://spotube.cc" val repositoryUrl = "https://github.com/team-spotube/spotube" val appVersionName = version val appBuildNumber = if (isNightly) nightlyBuildNumber else buildNumber diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt index 37a15a6f..ad9f5276 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt @@ -253,7 +253,7 @@ fun App( onDismiss = appUpdateService::dismissUpdate, onIgnore = appUpdateService::ignoreUpdate, onUpdate = { - runCatching { openUrlInBrowser("https://spotube.org/downloads/") } + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } appUpdateService.dismissUpdate() }) } diff --git a/packaging/aur/PKGBUILD.mustache b/packaging/aur/PKGBUILD.mustache index 0fa2e183..5fa0a92a 100644 --- a/packaging/aur/PKGBUILD.mustache +++ b/packaging/aur/PKGBUILD.mustache @@ -4,7 +4,7 @@ pkgver={{version}} pkgrel={{pkgrel}} pkgdesc="Open source, plugin powered music streaming client" arch=(x86_64 aarch64) -url="https://spotube.org" +url="https://spotube.cc" license=('AGPL-3.0-or-later') # libvlc + plugins for playback (FFmpeg demux/decode, GnuTLS for HTTPS streams, PulseAudio/PipeWire output), # WebKitGTK 4.1 for the webview, and the X11/GL/ALSA libraries the AWT/Skiko natives link diff --git a/packaging/chocolatey/spotube.nuspec.mustache b/packaging/chocolatey/spotube.nuspec.mustache index a864042f..77b2ba0c 100644 --- a/packaging/chocolatey/spotube.nuspec.mustache +++ b/packaging/chocolatey/spotube.nuspec.mustache @@ -13,7 +13,7 @@ enclosed in quotation marks, you should use an editor that supports UTF-8, not t spotube (Install) Team Spotube - https://spotube.org + https://spotube.cc https://rawcdn.githack.com/team-spotube/spotube/69a310c78f5ceaf4eab7dfee98f187d38211c9ba/assets/branding/spotube-logo.png 2026 Kingkor Roy Tirtho and Spotube Contributors @@ -21,7 +21,7 @@ enclosed in quotation marks, you should use an editor that supports UTF-8, not t https://github.com/team-spotube/spotube/blob/master/LICENSE true https://github.com/team-spotube/spotube - https://spotube.org + https://spotube.cc https://github.com/team-spotube/spotube/issues/new spotube music audio music-player streaming lyrics plugins foss Open source, plugin powered music streaming client diff --git a/packaging/linux/org.spotube.spotube.metainfo.xml.mustache b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache index 9519ac88..7c952522 100644 --- a/packaging/linux/org.spotube.spotube.metainfo.xml.mustache +++ b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache @@ -47,7 +47,7 @@ lyrics - https://spotube.org + https://spotube.cc https://github.com/team-spotube/spotube/issues https://github.com/team-spotube/spotube https://opencollective.com/spotube diff --git a/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache index fcaae4f9..7a9a168c 100644 --- a/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache +++ b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache @@ -6,10 +6,10 @@ PackageLocale: en-US Publisher: Team Spotube PublisherUrl: https://github.com/team-spotube PublisherSupportUrl: https://github.com/team-spotube/spotube/issues -PrivacyUrl: https://spotube.org/p/privacy +PrivacyUrl: https://spotube.cc/p/privacy Author: Team Spotube PackageName: Spotube -PackageUrl: https://spotube.org +PackageUrl: https://spotube.cc License: AGPL-3.0-or-later LicenseUrl: https://github.com/team-spotube/spotube/blob/master/LICENSE Copyright: Copyright (c) 2022-2026 Kingkor Roy Tirtho and Spotube Contributors From d7c7e3108d71d90430c1ef9611dcb4ba6f5855e1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 16:09:24 +0600 Subject: [PATCH 104/132] docs: add new features and enhancements for version 6.0.0 --- CHANGELOG.md | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2953c4bc..995442b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,156 @@ # Changelog +## [6.0.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0) (2026-10-26) + +### Features + +- Migration to Compose Multiplatform +- Add ChipTab component and refactor SearchScreen to use it +- Add AutocompleteTextField component and integrate it into SearchScreen +- Enhance AutocompleteTextField with focus management and dropdown behavior +- Enhance AppSidebar with improved button interactions and visual feedback +- Implement endless playback recommendations in DeviceAudioPlayerQueue +- Add GhostIconButton component and enhance button interactions in TrackList +- Replace IconButton with GhostIconButton in AppExpandedPlayer and AppLargePlayer for improved visual consistency +- Replace IconButton with GhostIconButton in PlayerQueueContent and PlayableCard for visual consistency +- Enhance UI components with LocalBaseUITheme for consistent styling and improved interactions +- Enhance button styles and interactions in Buttons and Theme components for improved UI consistency +- Simplify highlight implementation in UI components for improved readability and maintainability +- Add genre selection and tabs to HomeScreen for improved content navigation +- Implement artist overview screen with loading, error handling, and pagination for albums, related artists, and featured playlists +- Add genre and featured browsing capabilities to MediaBrowseHelper +- Implement playlist creation and editing functionality with UI components +- Add 'Add to Playlist' functionality with UI components and dialogs +- Add Toggle component and update UI to use it in place of Switch +- Implement settings section cards for updates, desktop, language, caching, and plugins +- Update icons in settings sections to use Iconsax library for improved UI consistency +- Replace FeatherIcons with Iconsax in PluginCard and PluginPermissionsDialog for improved UI consistency +- Refactor PluginPermissionsDialog to use custom UI components for improved consistency and readability +- Enhance UI with shared element transitions and improve state management in album and playlist screens +- Integrate Uniffi for audio metadata reading and writing with Lofty library +- Replace FeatherIcons with Iconsax in window controls and update sidebar with better design +- Implement haze effect and UI enhancements in AppLargePlayer and AppShell +- Enhance haze effect integration in AppShell and AppSidebar for improved UI experience +- Add adaptive dialog bottom sheet component and enhance plugin installation UI +- Enhance AdaptiveDialogBottomSheet with dismiss icon and improve plugin screen layout +- Implement CollectionView component and refactor Album, Playlist, SavedTracks screens for improved UI consistency +- Add current time management in AudioPlayer for improved playback control +- Simplify playback control logic in AudioPlayer by removing redundant state checks +- Introduce AudioPlayerInterface and refactor AudioPlayer implementations for better abstraction +- Update skipToNext and skipToPrevious methods in AudioPlayer for improved media item management +- Refactor LyricsViewModel to use AudioPlayerInterface for improved abstraction +- Add Discord Rich Presence integration for enhanced media playback visibility +- Implement System Tray integration for window management and media controls +- Add hover effect to menu items in System Tray for improved user experience +- Add SSH debug session setup on failure for improved troubleshooting +- Implement blacklist functionality for tracks and artists with UI updates +- **systemtray**: Self-manage tray lifecycle via Koin DI +- **plugin_interfaces**: Update Maven publishing configuration and add GitHub Actions workflow +- **plugin_discovery**: Implement GitHub plugin discovery and add logo support in UI +- **plugin_discovery**: Implement GitHub plugin discovery and add logo support and information dialog in UI +- **plugin_install**: Add plugin installation dialog and release fetching functionality +- Update dependencies and configure compose hot reload settings +- Improve NewPipe video rank matching +- Integrate Nucleus framework for enhanced window management and webview support +- Implement system tray functionality with Compose Native Tray integration +- **mediacontrol**: Implement SystemMediaControlService for OS media integration +- **devices**: Add device discovery and remote control features +- **deeplinks**: Implement deep link handling for jam sessions +- **remote-control**: Implement remote playback functionality and UI components +- **remote-control**: Enhance queue management and player state synchronization +- **remote-control**: Enhance remote playback functionality with new commands and UI integration +- **webrtc**: Update ICE server configuration and enhance logging for peer connection events +- **jam-session**: Add connection state management and update UI feedback for session status +- **jam-session**: Enhance jam session functionality with improved playback synchronization and error handling +- **remote-control**: Refactor service registration and cleanup logic for improved reliability +- **jam-session**: Integrate MQTT broker configuration and enhance jam session settings +- **jam-session**: Implement track addition to jam session and enhance UI feedback for jam state +- **jam-session**: Enhance guest functionality with track addition and participant moderation options +- **release**: Add support for Linux ARM64 builds and artifact uploads +- **player-options**: Add PlayerOptionsViewModel and enhance player UI with options menu +- **player-options**: Enhance options menu with localized strings and icons +- **jam-settings**: Implement JamSettings screen and view model for broker configuration +- Refactor track source management to support plugin-specific caching and retrieval +- Implement shuffle play functionality for albums, playlists, and saved tracks +- **navigation**: Refactor sidebar and bottombar navigation structure with new tab and group models +- **search**: Enhance LazyRow with drag scrolling functionality +- **autocomplete**: Simplify focus management and remove unused key event handling +- **update**: Implement update checker with dialog for available updates +- **update**: Refactor update checking mechanism and implement new dialog for app and plugin updates +- **scrobble**: Implement ScrobbleManager to handle track scrobbling and integrate with audio player +- **welcome**: Implement onboarding flow with multiple pages and donation options +- **previews**: Add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields +- **settings**: Add 'About' section and improve header visibility in settings sections +- **surface**: Implement Frutiger-Aero themed surfaces with glass and acrylic styles +- **ui**: Replace Scaffold with BaseScaffold for consistent surface theming across screens +- **ui**: New custom surface integration on all components +- **onboarding**: Enhance welcome screen with structured guide steps and improved layout +- **player**: Add content descriptions for play, pause, and add to queue actions +- **support**: Implement support reminder dialog and donation options +- **player**: Refactor artist display and improve search field labels +- **search**: Enhance search functionality with recent search suggestions and improved UI components +- **sidebar**: Adjust spacing in sidebar and setting cards for improved layout +- **jam**: Implement group jam functionality with UI components and state management +- **library**: Enhance library UI with new tab titles, filtering options, and improved state management +- **library**: Add Library screen list mode beside grid mode, Local Track, Download screen, Local Library folders dialog visual improvement +- **ui**: Integrate LocalTextStyle for dynamic tinting in player controls and UI components +- **navigation**: Add custom navigation transition specifications for push and pop actions +- **i18n**: Replace hardcoded strings with localized resources across multiple components +- **plugin**: Implement MetadataPluginGuard to manage plugin dependencies and user prompts +- **ui**: Replace IndeterminateProgress with new loading components and enhance dialog structures +- **ui**: Update MillenniumModalBottomSheet with compact padding and spacing adjustments +- **plugin**: Add login prompt for metadata plugin access and implement authentication view model +- **user-agents**: Implement dynamic user agent fetching and caching mechanism +- **lyrics**: Implement SyncedLyricsList component and enhance LyricsViewModel for better state management +- **server**: Implement port fallback mechanism for playback proxy server +- **window**: Implement window state persistence and restoration +- Enhance Android build configuration with stable/nightly flavors and update launcher icons +- **graalvm**: Enable GraalVM native image support and update packaging configurations +- **local-tracks**: Implement LocalFolder screen and view model for folder navigation +- **navigation**: Implement fade-through and stack transition animations for navigation +- **remote**: Redesign remote control and devices screens, fix connection issues +- Download diagnostics, live connect status, remote settings on devices screen +- Enhance loading states for various UI components and update dependencies +- **packaging**: Flathub, AUR, WinGet and Chocolatey publishing +- **plugin**: Implement automatic plugin updates and enhance JSON parsing + +### Bug Fixes + +- **newpipe**: Fallback to muxed streams if no audio stream is available +- Dismiss search dropdown and keyboard on submission +- Custom image helper null exception +- **plugin**: Reload Zipline service on same-version replace +- Convert buildCurrentMediaItemList to a suspend function for coroutine support +- Enhance Discord RPC connection management with cooldown and error handling +- Cached track audio not being invalidated causing alternative track source to not being played +- Play next not working +- Update pendingNextIndex when playing a track from the playlist +- **webview**: Linux webview crashing the app +- Add mavenLocal() to plugin and dependency resolution management +- **plugin**: Handle service closure exception during authentication check +- **sidebar**: Add spacer to AppSidebar for improved layout +- **remote-control**: Update connection handling and improve logging for remote control events +- Add window insets to top bar and bottom navigation bar, queue sheet +- **stream-proxy**: Stream upstream audio instead of buffering the whole track +- **newpipe**: Fall back through multiple searches when ISRC search misses +- **audio-api**: Enhance fallback mechanism for text search results in YouTube +- **audio-player**: Android shuffle wasnt working +- **stream-proxy**: Improve logging for cache timeout and handle disconnection scenarios +- **macos**: Add GraalVM runtime dependency and initialize GraalVm in main function +- **android**: Create intermediate directories for Rust targets to prevent runtime errors +- **android**: Update sharing functionality to use chooser intent with new task flag +- **android**: Simplify service start logic for PlaybackService to avoid crashes on background playback +- **android**: Update playback state management to ensure guests follow host's play state +- **android**: App stuck on splash screen from main-thread string loading +- **downloads**: Respect download format/quality and never save manifests or video +- **ios**: Run Zipline on an 8 MiB stack thread +- **server**: Don't crash when the proxy port is still in use +- **ios**: Stream formats AVPlayer can play +- **gradle**: Restore VFS watch setting and clean up properties +- **build**: Restore version catalog lost in dev merge +- **ios**: Make the login WebView identify as Mobile Safari +- Update website URLs from spotube.org to spotube.cc + ## [5.1.2](https://github.com/team-spotube/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) ### Bug Fixes From 67e452493ae8ca5a5992f747782b5a83e0813899 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 16:34:02 +0600 Subject: [PATCH 105/132] feat(release): add beta release type A beta is the stable build released as -beta.N, with N set via betaNumber in composeApp/build.gradle.kts. Betas and stable releases are drafts; only nightly publishes immediately. --- .github/workflows/spotube-release-binary.yml | 10 ++++++---- README.md | 2 +- composeApp/build.gradle.kts | 10 +++++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index c3ddf0f8..e612fa07 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -10,6 +10,7 @@ on: default: 'stable' options: - stable + - beta - nightly dry_run: description: 'Dry run (build & upload artifacts only, skip creating release)' @@ -26,7 +27,7 @@ on: permissions: contents: read -# Never cancel a running release when another is dispatched, and let stable and nightly run side by side +# 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 }} cancel-in-progress: false @@ -215,7 +216,7 @@ jobs: fi # Desktop entry + metainfo, with the release history from git-cliff ./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" . + packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type == 'nightly' && 'nightly' || 'stable' }}" . - name: Upload tarball uses: actions/upload-artifact@v7 @@ -335,7 +336,7 @@ jobs: fi # Desktop entry + metainfo, with the release history from git-cliff ./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" . + packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type == 'nightly' && 'nightly' || 'stable' }}" . - name: Upload tarball uses: actions/upload-artifact@v7 @@ -538,8 +539,9 @@ jobs: - name: Set release variables id: vars run: | - if [ "${{ inputs.release_type }}" = "stable" ]; then + if [ "${{ inputs.release_type }}" != "nightly" ]; then chmod +x gradlew + # printVersion includes the -beta.N suffix when betaNumber is set in build.gradle.kts VERSION=$(./gradlew -q :composeApp:printVersion) TAG="v${VERSION}" echo "is_prerelease=false" >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 833b6e2c..2cc7453b 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Android picks the Rust profile from its build type (`debug` or `release`) on its ### Releases -Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable` or `nightly`. Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK. +Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable`, `beta` or `nightly`. A beta is the stable build released like a stable release (draft, not a pre-release), tagged `v-beta.N` (set `betaNumber` in `composeApp/build.gradle.kts` to N, and back to `null` for stable). Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK. ## 👥 The Spotube team diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 571aa28d..a72f675f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -48,6 +48,10 @@ plugins { val bundleId = "org.spotube.spotube" val version = "6.0.0" +// Set to N to release -beta.N (same app id, icons and name as stable); null for a plain stable release. +// Installer metadata (packageVersion) must stay numeric, so it keeps using plain `version`. +val betaNumber: Int? = 0 +// Betas share the stable app id, so bump this for every beta: Android only updates to a higher versionCode. val buildNumber = 46 // Desktop icon channel; Android picks its icons via the stable/nightly product flavor. Use -Pnightly=true. val isNightly = providers.gradleProperty("nightly").map { it.toBoolean() }.getOrElse(false) @@ -70,7 +74,7 @@ val isProductionBuild = providers.gradleProperty("production").map { it.toBoolea val targetName = "Spotube" val homePageUrl = "https://spotube.cc" val repositoryUrl = "https://github.com/team-spotube/spotube" -val appVersionName = version +val appVersionName = if (betaNumber != null) "$version-beta.$betaNumber" else version val appBuildNumber = if (isNightly) nightlyBuildNumber else buildNumber val appRepositoryUrl = repositoryUrl // CI reads the release version from here instead of scraping this file. @@ -375,8 +379,8 @@ android { applicationId = bundleId minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = buildNumber - versionName = version + versionCode = appBuildNumber + versionName = appVersionName } // stable = production channel, nightly = beta channel (own icon, name and installable side by side) flavorDimensions += "channel" From 1b4fbe8d2523d0ed972d64dce0e003ae2d756e99 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 16:40:46 +0600 Subject: [PATCH 106/132] chore: update changelog for 6.0.0-beta.0 and optimize Gradle build settings --- .github/workflows/spotube-release-binary.yml | 11 ++++++++++- CHANGELOG.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index e612fa07..80b44c2b 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -486,7 +486,16 @@ jobs: env: # Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew :composeApp:packageGraalvmDmg -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + # The free macOS runner has 3 cores / 7 GB. With the default 4g Gradle + 3g Kotlin daemons idling, + # native-image only gets ~3 GB and spends ~40% of its time in GC (47 min build vs ~15 on Linux). + # A single-use daemon with a smaller heap and in-process Kotlin compilation leaves the RAM to it. + run: > + ./gradlew :composeApp:packageGraalvmDmg + --no-daemon + -Dorg.gradle.jvmargs="-Xmx3g -XX:MaxMetaspaceSize=1g" + -Pkotlin.compiler.execution.strategy=in-process + -Pnightly=${{ inputs.release_type == 'nightly' }} + -PbuildNumber=${{ github.run_number }} - name: Rename macOS artifact run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 995442b6..5bfa5a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [6.0.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0) (2026-10-26) +## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26) ### Features From cde359a9de918d0de28b0cbc23f6b783d750a3ee Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 16:52:56 +0600 Subject: [PATCH 107/132] cd: add iOS build process and package IPA for release --- .github/workflows/spotube-release-binary.yml | 73 +++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 80b44c2b..43181913 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -514,6 +514,75 @@ jobs: with: limit-access-to-actor: true + build-ios: + name: iOS + needs: prepare-deps + timeout-minutes: 120 + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make gradlew executable + run: chmod +x gradlew + + # Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading + # (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate. + # The Gradle build phase inside Xcode reads these as project properties. + - name: Build unsigned app + env: + ORG_GRADLE_PROJECT_nightly: ${{ inputs.release_type == 'nightly' }} + ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }} + run: > + xcodebuild + -project iosApp/iosApp.xcodeproj + -scheme iosApp + -configuration Release + -sdk iphoneos + -destination 'generic/platform=iOS' + -derivedDataPath build/ios + CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_REQUIRED=NO + CODE_SIGN_IDENTITY="" + DEVELOPMENT_TEAM="" + build + + - name: Package IPA + run: | + APP=$(find build/ios/Build/Products/Release-iphoneos -maxdepth 1 -name "*.app" -type d | head -1) + mkdir Payload + cp -R "$APP" Payload/ + zip -qry Spotube-ios.ipa Payload + + - name: Upload IPA + uses: actions/upload-artifact@v7 + with: + name: ios-ipa + path: Spotube-ios.ipa + + - name: Setup tmate session on failure + if: ${{ inputs.debug_ssh && failure() }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + create-release: name: Create GitHub Release if: ${{ !inputs.dry_run }} @@ -523,6 +592,7 @@ jobs: - build-linux-arm64 - build-windows - build-macos + - build-ios runs-on: ubuntu-latest permissions: contents: write @@ -572,7 +642,7 @@ jobs: run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md - for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg; do + for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg ios-ipa; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -612,3 +682,4 @@ jobs: linux-tarball-arm64/*.tar.xz windows-exe/*.exe macos-dmg/*.dmg + ios-ipa/*.ipa From 91867f571d752d9abab238b973e0cb75b19c7dc2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 17:39:24 +0600 Subject: [PATCH 108/132] ci: remove nightly tag movement from release workflow --- .github/workflows/spotube-release-binary.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 43181913..f12a0d2e 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -652,13 +652,6 @@ jobs: fi done - # The rolling nightly tag already exists, and the release action never moves an existing tag - - name: Move nightly tag to this commit - if: ${{ inputs.release_type == 'nightly' }} - run: | - git tag -f nightly "$GITHUB_SHA" - git push -f origin refs/tags/nightly - - name: Create Release uses: softprops/action-gh-release@v3 with: From 31a3233c799e64e1c1032935576c98e008247ea3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 18:11:35 +0600 Subject: [PATCH 109/132] ci: remove nightly tag movement from release workflow --- gradle.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index a49b0977..831e3793 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,9 +20,11 @@ kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M kotlin.mpp.stability.nowarn=true kotlin.incremental=true +kotlin.native.jvmArgs=-Xmx6g #Gradle -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g +# The Kotlin/Native release framework link (iOS) runs in the Gradle daemon and OOMs below ~6g +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g org.gradle.configuration-cache=true org.gradle.caching=true From 79266fd483e92fa6f97900bfe3826cc5ca726959 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 16:35:27 +0600 Subject: [PATCH 110/132] fix(windows): window not spawning due to early COM initialization --- .../kotlin/org/spotube/spotube/main.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index a8d10f0e..17f57aec 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -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) { From ea01dec6ea43d6e3980a07218f8bbf198e43d602 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 18:49:20 +0600 Subject: [PATCH 111/132] fix(settings): palette swatches follow the active light/dark appearance Co-Authored-By: Claude Sonnet 5.5 --- .../modules/settings/sections/AppearanceSection.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt index 9b7ba138..3186eb9e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt @@ -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 From 5b36edd259915ba7ffe1217198c368eb9741cb6b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 18:49:20 +0600 Subject: [PATCH 112/132] 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 --- .../org/spotube/spotube/core/DebugConfig.kt | 2 + .../spotube/core/server/StreamProxy.kt | 28 ++++++++- .../core/server/StreamingUrlRepository.kt | 25 ++++---- .../modules/settings/SettingsModels.kt | 8 +-- .../core/server/StreamProxyHeadersTest.kt | 57 +++++++++++++++++++ .../StreamingUrlRepositoryBehaviorTest.kt | 10 +++- .../settings/StreamingFormatDefaultsTest.kt | 6 +- .../core/audioplayer/AudioPlayer.jvm.kt | 23 +++++--- gradle/libs.versions.toml | 2 +- 9 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt index b1e5941d..18792453 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index 900d830a..338803bc 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -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() } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 19692bf6..11a8ae60 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -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() 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> = 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) + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index 8d0fa86d..f22688a3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -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), diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt new file mode 100644 index 00000000..441a88ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt @@ -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 . + */ + +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"]) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index f297b17a..28feb80c 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -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) diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt index cc35c849..c25d82a6 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -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 diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt index 37cb7a42..a5f771bc 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -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(extraBufferCapacity = 1) actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() - actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = + _currentMediaItem.asStateFlow() actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() actual override val durationFlow: StateFlow = _duration.asStateFlow() actual override val positionFlow: StateFlow = _position.asStateFlow() - actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = + _bufferingPosition.asStateFlow() actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() actual override val playbackSpeedFlow: StateFlow = _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, autoPlay: Boolean, startPosition: Int) { + actual override suspend fun load( + playlist: List, + autoPlay: Boolean, + startPosition: Int + ) { lock.withLock { if (disposed) return diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 838df455..c167d59c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,7 +72,7 @@ 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" From aca3aeb592d2873620a29e5d38a2cdc107382407 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 18:53:47 +0600 Subject: [PATCH 113/132] 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 --- composeApp/build.gradle.kts | 1 + .../spotube/core/ui/theming/AppTheme.kt | 3 +-- .../core/ui/theming/SystemDarkTheme.kt | 24 ++++++++++++++++++ .../core/ui/theming/SystemDarkTheme.jvm.kt | 25 +++++++++++++++++++ .../core/ui/theming/SystemDarkTheme.mobile.kt | 24 ++++++++++++++++++ gradle/libs.versions.toml | 1 + 6 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt create mode 100644 composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a72f675f..99794847 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt index 6720efb9..b283451e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt new file mode 100644 index 00000000..3efa4474 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt @@ -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 . + */ + +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 diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt new file mode 100644 index 00000000..eae0a660 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt @@ -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 . + */ + +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() diff --git a/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt new file mode 100644 index 00000000..047248e3 --- /dev/null +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt @@ -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 . + */ + +package org.spotube.spotube.core.ui.theming + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable + +@Composable +actual fun isSystemDarkTheme(): Boolean = isSystemInDarkTheme() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c167d59c..ccc34f1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -134,6 +134,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" } From eb972c8633ebcb0d8d6f2a449a301c7031221c57 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 29 Oct 2026 11:25:22 +0600 Subject: [PATCH 114/132] fix(dependencies): update Android target SDK and various library versions --- .github/workflows/spotube-release-binary.yml | 137 ++++++------------- gradle/libs.versions.toml | 28 +--- 2 files changed, 46 insertions(+), 119 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index f12a0d2e..cc69ab89 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -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,67 +42,29 @@ 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 - jobs: - prepare-deps: - name: Prepare Dependencies - runs-on: macos-latest + validate-inputs: + name: Validate Inputs + runs-on: ubuntu-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: Set up Android SDK - uses: android-actions/setup-android@v4 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Publish compose-webview to mavenLocal - working-directory: compose-webview + - name: Single platform builds are nightly only + if: ${{ inputs.platform != 'all' && inputs.release_type != 'nightly' }} run: | - chmod +x gradlew - ./gradlew publishToMavenLocal - - - name: Upload maven local - uses: actions/upload-artifact@v7 - with: - name: maven-local - path: ~/.m2/repository/ + echo "::error::Building a single platform is only supported for nightly releases" + exit 1 build-android: name: Android - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }} timeout-minutes: 120 runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -152,7 +127,8 @@ jobs: build-linux: name: Linux Desktop - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }} timeout-minutes: 120 runs-on: ubuntu-latest steps: @@ -161,12 +137,6 @@ jobs: with: fetch-depth: 0 - - 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 @@ -265,7 +235,8 @@ jobs: build-linux-arm64: name: Linux Desktop (aarch64) - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }} timeout-minutes: 120 runs-on: ubuntu-24.04-arm steps: @@ -281,12 +252,6 @@ jobs: with: packages: tools platform-tools ndk;28.2.13676358 - - 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 @@ -385,18 +350,13 @@ jobs: build-windows: name: Windows Desktop - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }} timeout-minutes: 120 runs-on: windows-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -448,18 +408,13 @@ jobs: build-macos: name: macOS Desktop - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -516,18 +471,13 @@ jobs: build-ios: name: iOS - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -585,8 +535,10 @@ 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 - build-android - build-linux - build-linux-arm64 @@ -599,13 +551,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: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -639,6 +584,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 +604,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: | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccc34f1f..020d99eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,16 +14,12 @@ # along with this program. If not, see . [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" +compose-webview = "1.0.3" 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.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" } @@ -153,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" } @@ -170,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" } @@ -189,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" } From 8d4ea82a7eab6eef9a31358e6ab783845efb9319 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 29 Oct 2026 15:01:43 +0600 Subject: [PATCH 115/132] fix(build): add headless mode for Linux and update CI to prepare dependencies --- .github/workflows/spotube-release-binary.yml | 100 +++++++++++++++++-- composeApp/build.gradle.kts | 6 ++ gradle/libs.versions.toml | 2 +- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index cc69ab89..d666c54d 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -45,6 +45,11 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }} cancel-in-progress: false +env: + COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview + # fix/linux-wayland-input + COMPOSE_WEBVIEW_COMMIT: b11ac748de1219f9432338c067a2c5dafef3072a + jobs: validate-inputs: name: Validate Inputs @@ -56,15 +61,61 @@ jobs: echo "::error::Building a single platform is only supported for nightly releases" exit 1 + prepare-deps: + name: Prepare Dependencies + needs: validate-inputs + runs-on: macos-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: Set up Android SDK + uses: android-actions/setup-android@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Publish compose-webview to mavenLocal + working-directory: compose-webview + run: | + chmod +x gradlew + ./gradlew publishToMavenLocal + + - name: Upload maven local + uses: actions/upload-artifact@v7 + with: + name: maven-local + path: ~/.m2/repository/ + build-android: name: Android - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }} timeout-minutes: 120 runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -127,7 +178,7 @@ jobs: build-linux: name: Linux Desktop - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }} timeout-minutes: 120 runs-on: ubuntu-latest @@ -142,6 +193,12 @@ jobs: 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: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -235,7 +292,7 @@ jobs: build-linux-arm64: name: Linux Desktop (aarch64) - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }} timeout-minutes: 120 runs-on: ubuntu-24.04-arm @@ -257,6 +314,12 @@ jobs: 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: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -350,13 +413,19 @@ jobs: build-windows: name: Windows Desktop - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }} timeout-minutes: 120 runs-on: windows-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -408,13 +477,19 @@ jobs: build-macos: name: macOS Desktop - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -471,13 +546,19 @@ jobs: build-ios: name: iOS - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -539,6 +620,7 @@ jobs: if: ${{ !inputs.dry_run && !cancelled() && !contains(needs.*.result, 'failure') }} needs: - validate-inputs + - prepare-deps - build-android - build-linux - build-linux-arm64 @@ -551,6 +633,12 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 99794847..f60ba709 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -646,6 +646,12 @@ tasks.withType().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() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 020d99eb..d8ec04eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ ziplineVersion = "1.28.0" ktor = "3.5.1" dnssdkt = "1.1.0" filekit = "0.14.2" -compose-webview = "1.0.3" +compose-webview = "0.1.0-SNAPSHOT" composeNativeTray = "2.1.6" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" From fa9b42cf10d329981ba5ca89762e73a9398db8ef Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 29 Oct 2026 16:03:55 +0600 Subject: [PATCH 116/132] cd(build): add support for building WebView native libraries on Linux and Windows --- .github/workflows/spotube-release-binary.yml | 87 +++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index d666c54d..418db54e 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -61,9 +61,75 @@ jobs: 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 + needs: [validate-inputs, build-webview-natives] runs-on: macos-latest steps: - name: Checkout ComposeNativeWebview @@ -74,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: @@ -95,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: From 2c1148a126db5c5a7dfa847f953914964e987f0e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Oct 2026 10:33:11 +0600 Subject: [PATCH 117/132] chore: remove devil's name --- CHANGELOG.md | 34 +++++++++---------- .../composeResources/values-ar/strings.xml | 2 +- .../composeResources/values-bn/strings.xml | 2 +- .../composeResources/values-de/strings.xml | 2 +- .../composeResources/values-es/strings.xml | 2 +- .../composeResources/values-fa/strings.xml | 2 +- .../composeResources/values-fr/strings.xml | 2 +- .../composeResources/values-hi/strings.xml | 2 +- .../composeResources/values-id/strings.xml | 2 +- .../composeResources/values-it/strings.xml | 2 +- .../composeResources/values-ja/strings.xml | 2 +- .../composeResources/values-ko/strings.xml | 2 +- .../composeResources/values-nl/strings.xml | 2 +- .../composeResources/values-pl/strings.xml | 2 +- .../composeResources/values-pt/strings.xml | 2 +- .../composeResources/values-ru/strings.xml | 2 +- .../composeResources/values-th/strings.xml | 2 +- .../composeResources/values-tr/strings.xml | 2 +- .../composeResources/values-uk/strings.xml | 2 +- .../composeResources/values-vi/strings.xml | 2 +- .../values-zh-rTW/strings.xml | 2 +- .../composeResources/values-zh/strings.xml | 2 +- .../composeResources/values/strings.xml | 2 +- .../spotube/core/webview/SafariUserAgent.kt | 2 +- .../plugin_apis/scrobble/ScrobbleModels.kt | 2 +- 25 files changed, 41 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bfa5a20..3c57ce82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -290,8 +290,8 @@ - **desktop**: double titlebar in local library folders and massive space in overlay player - lastfm form broken in other locales #2447 -- spotify login broken due to new totp requirement #2494 -- spotify authentication 429 errors +- Greedy Green Corp login broken due to new totp requirement #2494 +- Greedy Green Corp authentication 429 errors ### Features @@ -326,7 +326,7 @@ - **android**: home widget support (#2148) - rewrite entire app in shadcn-ui replacing material eww - grid/list customizable playbutton view -- flag to hide spotify generated images with patterns +- flag to hide Greedy Green Corp generated images with patterns - show placeholder images where there is no item or on empty page - pause playback when no internet connection - implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) @@ -552,7 +552,7 @@ [#1463](https://github.com/team-spotube/spotube/issues/1463) [#1505](https://github.com/team-spotube/spotube/issues/1505) ([d2683c5](https://github.com/team-spotube/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) -- spotify friends and user profile icon (mobile) showing when not authenticated +- Greedy Green Corp friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/team-spotube/spotube/issues/1410) ([9bccbc9](https://github.com/team-spotube/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) - **updater:** dead link @@ -576,7 +576,7 @@ ### Features -- add Spotify homepage personalized recommendations +- add Greedy Green Corp homepage personalized recommendations ([#1402](https://github.com/team-spotube/spotube/issues/1402)) ([9e25c74](https://github.com/team-spotube/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) - add user profile page @@ -684,7 +684,7 @@ - add create playlist button in add playlist dialog ([2168a64](https://personal.github.com/team-spotube/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) -- add spotify friends activity +- add Greedy Green Corp friends activity ([#1130](https://personal.github.com/team-spotube/spotube/issues/1130)) ([7983932](https://personal.github.com/team-spotube/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) - **deep-link:** add track opening page @@ -1436,7 +1436,7 @@ ([448c9b3](https://github.com/team-spotube/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) - **macos:** crashing on startup ([c46b428](https://github.com/team-spotube/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) -- spotify query hooks overriding default query params +- Greedy Green Corp query hooks overriding default query params ([ec9a02e](https://github.com/team-spotube/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) ## [2.7.0](https://github.com/team-spotube/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) @@ -1469,7 +1469,7 @@ ([9080441](https://github.com/team-spotube/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) - individual shuffle and repeat/loop button of player ([f79223c](https://github.com/team-spotube/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) -- **lyrics:** use official spotify API for fetching lyrics and add zoom controls +- **lyrics:** use official Greedy Green Corp API for fetching lyrics and add zoom controls ([10d0660](https://github.com/team-spotube/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) - **mobile:** pull to refresh support in all refreshable list views ([9f959ce](https://github.com/team-spotube/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) @@ -1921,7 +1921,7 @@ - private playlists of current user aren't shown fix (https://github.com/team-spotube/spotube/issues/92) -- refresh token error causing re-login (culprit: internal lib spotify-dart) +- refresh token error causing re-login (culprit: internal lib Greedy Green Corp-dart) - Typo in Login instructions URL # v2.1.0 @@ -1937,7 +1937,7 @@ ### Improved -- Spotify track to youtube track algorithm +- Greedy Green Corp track to youtube track algorithm - Genius lyrics matching algorithm - Download track. Checks if already exists & replaces on user command - Wide screen responsiveness & adaptation @@ -1950,7 +1950,7 @@ (https://github.com/team-spotube/spotube/issues/46) - Theme modification state doesn't persist (https://github.com/team-spotube/spotube/issues/54) -- Wrong URI path for "Login with Spotify" tutorial +- Wrong URI path for "Login with Greedy Green Corp" tutorial (https://github.com/team-spotube/spotube/issues/69) - Card shadow showing in the background of TitleBar & Searchbar @@ -2124,17 +2124,17 @@ ### Bug Fixes -- `access_token not found` Error after OAuth Login with Spotify credentials +- `access_token not found` Error after OAuth Login with Greedy Green Corp credentials (used to need a restart of the app to load the access_token) -- `access_token not found` Error after OAuth Login with Spotify credentials +- `access_token not found` Error after OAuth Login with Greedy Green Corp credentials (used to need a restart of the app to load the access_token) - Volume level wasn't cached even after changing volume # v0.0.1 -Spotube v0.0.1 - initial release of the open source software for playing Spotify +Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp music using Youtube public API -Spotube v0.0.1 - initial release of the open source software for playing Spotify +Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp music using Youtube public API ### New @@ -2143,7 +2143,7 @@ music using Youtube public API - Playback Queue - Save to Liked Tracks/Playlists - Bypass API rate limitation on basic usage using personal developer Apps for - spotify API + Greedy Green Corp API - Bypass API rate limitation on basic usage using personal developer Apps for - spotify API + Greedy Green Corp API - Youtube search & get handled using scrape-yt diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index 6671d6d9..9eb21d8c 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -402,7 +402,7 @@ كيفية استخدام Spotube أربع خطوات سريعة وتبدأ الاستماع. تسجيل الدخول - اربط حسابك على Spotify، أو اختر إضافة الكتالوج الخاصة بك. + اربط حسابك، أو اختر إضافة الكتالوج الخاصة بك. ابحث وشغّل اعثر على أي أغنية أو ألبوم أو فنان، ثم اضغط تشغيل. استمع بلا إنترنت diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index a3c26ed2..268cdf95 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -386,7 +386,7 @@ Spotube যেভাবে ব্যবহার করবেন চারটি সহজ ধাপ, তারপরই গান শুনুন। সাইন ইন - আপনার Spotify অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন। + আপনার অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন। খুঁজুন ও প্লে করুন যেকোনো গান, অ্যালবাম বা শিল্পী খুঁজুন, তারপর প্লে চাপুন। অফলাইনে শুনুন diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 4a4115dd..2f965807 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -386,7 +386,7 @@ So nutzt du Spotube Vier schnelle Schritte und du hörst Musik. Anmelden - Verbinde dein Spotify-Konto oder wähle ein eigenes Katalog-Plugin. + Verbinde dein Konto oder wähle ein eigenes Katalog-Plugin. Suchen und abspielen Finde einen Song, ein Album oder einen Künstler und tippe auf Abspielen. Offline hören diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 48c78bf3..b4ae5fa1 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -390,7 +390,7 @@ Cómo usar Spotube Cuatro pasos rápidos y a escuchar. Inicia sesión - Conecta tu cuenta de Spotify o elige tu propio plugin de catálogo. + Conecta tu cuenta o elige tu propio plugin de catálogo. Busca y reproduce Encuentra cualquier canción, álbum o artista y dale al play. Escucha sin conexión diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 8a5659ed..51374e7e 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -386,7 +386,7 @@ نحوهٔ استفاده از Spotube چهار گام سریع و مشغول شنیدن هستید. ورود - حساب Spotify خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید. + حساب خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید. جستجو و پخش هر آهنگ، آلبوم یا هنرمندی را پیدا کنید و پخش را بزنید. گوش دادن آفلاین diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 2737c4bc..45a31bd6 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -390,7 +390,7 @@ Comment utiliser Spotube Quatre étapes rapides et c\'est parti. Se connecter - Connectez votre compte Spotify ou choisissez votre propre plugin de catalogue. + Connectez votre compte ou choisissez votre propre plugin de catalogue. Rechercher et écouter Trouvez n\'importe quel titre, album ou artiste, puis lancez la lecture. Écouter hors connexion diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 2ed9b459..29e2b1d2 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -385,7 +385,7 @@ Spotube कैसे इस्तेमाल करें चार आसान स्टेप और आप सुनना शुरू कर सकते हैं। साइन इन करें - अपना Spotify अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें। + अपना अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें। खोजें और चलाएं कोई भी गाना, एल्बम या कलाकार खोजें, फिर प्ले दबाएं। ऑफ़लाइन सुनें diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index f3e1ca6a..3f46bc82 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -382,7 +382,7 @@ Cara menggunakan Spotube Empat langkah singkat dan kamu siap mendengarkan. Masuk - Hubungkan akun Spotify-mu, atau pilih plugin katalogmu sendiri. + Hubungkan akunmu, atau pilih plugin katalogmu sendiri. Cari dan putar Temukan lagu, album, atau artis apa pun, lalu tekan putar. Dengarkan offline diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index b6925383..2d956a2c 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -390,7 +390,7 @@ Come usare Spotube Quattro rapidi passaggi e sei pronto ad ascoltare. Accedi - Collega il tuo account Spotify o scegli il tuo plugin di catalogo. + Collega il tuo account o scegli il tuo plugin di catalogo. Cerca e riproduci Trova qualsiasi brano, album o artista, poi premi play. Ascolta offline diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index a915d409..27ea528a 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -382,7 +382,7 @@ Spotubeの使い方 簡単な4ステップで、すぐに聴けます。 サインイン - Spotifyアカウントを連携するか、お好みのカタログプラグインを選んでください。 + アカウントを連携するか、お好みのカタログプラグインを選んでください。 検索して再生 曲、アルバム、アーティストを探して再生しましょう。 オフラインで聴く diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 5464dc07..67fb729e 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -382,7 +382,7 @@ Spotube 사용 방법 간단한 네 단계면 바로 감상할 수 있어요. 로그인 - Spotify 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요. + 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요. 검색하고 재생하기 노래, 앨범, 아티스트를 찾은 다음 재생을 누르세요. 오프라인으로 감상하기 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index 337ad03c..a5e4197e 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -386,7 +386,7 @@ Zo gebruik je Spotube Vier snelle stappen en je luistert al. Inloggen - Koppel je Spotify-account of kies je eigen catalogusplug-in. + Koppel je account of kies je eigen catalogusplug-in. Zoeken en afspelen Vind elk nummer, album of elke artiest en druk op afspelen. Offline luisteren diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index f5b66965..5af41a10 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -394,7 +394,7 @@ Jak korzystać ze Spotube Cztery szybkie kroki i już słuchasz. Zaloguj się - Połącz konto Spotify lub wybierz własną wtyczkę katalogu. + Połącz konto lub wybierz własną wtyczkę katalogu. Szukaj i odtwarzaj Znajdź dowolny utwór, album lub wykonawcę i naciśnij Odtwórz. Słuchaj offline diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index fed0a741..ce987e2b 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -390,7 +390,7 @@ Como usar o Spotube Quatro passos rápidos e você já está ouvindo. Entrar - Conecte sua conta do Spotify ou escolha seu próprio plugin de catálogo. + Conecte sua conta ou escolha seu próprio plugin de catálogo. Busque e toque Encontre qualquer música, álbum ou artista e aperte o play. Ouça offline diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index e499d01d..8128a8ce 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -394,7 +394,7 @@ Как пользоваться Spotube Четыре простых шага — и музыка уже играет. Вход - Подключите аккаунт Spotify или выберите свой плагин каталога. + Подключите аккаунт или выберите свой плагин каталога. Ищите и слушайте Найдите любой трек, альбом или исполнителя и нажмите «Воспроизвести». Слушайте офлайн diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 5c5b861d..ab470a92 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -382,7 +382,7 @@ วิธีใช้ Spotube แค่ 4 ขั้นตอนง่าย ๆ ก็เริ่มฟังได้เลย เข้าสู่ระบบ - เชื่อมต่อบัญชี Spotify ของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง + เชื่อมต่อบัญชีของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง ค้นหาและเล่น ค้นหาเพลง อัลบั้ม หรือศิลปิน แล้วกดเล่น ฟังแบบออฟไลน์ diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 4999b886..54d5ed93 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -386,7 +386,7 @@ Spotube nasıl kullanılır Dört hızlı adım ve dinlemeye başlıyorsun. Giriş yap - Spotify hesabını bağla veya kendi katalog eklentini seç. + hesabını bağla veya kendi katalog eklentini seç. Ara ve çal Herhangi bir şarkıyı, albümü veya sanatçıyı bul, sonra çal\'a bas. Çevrimdışı dinle diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index 1f07bb5d..0fd2d19b 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -394,7 +394,7 @@ Як користуватися Spotube Чотири прості кроки — і музика вже грає. Вхід - Підключіть акаунт Spotify або виберіть власний плагін каталогу. + Підключіть акаунт або виберіть власний плагін каталогу. Шукайте й слухайте Знайдіть будь-який трек, альбом чи виконавця й натисніть «Відтворити». Слухайте офлайн diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index b6d4f5ea..39ae5edc 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -382,7 +382,7 @@ Cách dùng Spotube Bốn bước nhanh là bạn đã có thể nghe nhạc. Đăng nhập - Kết nối tài khoản Spotify hoặc chọn plugin danh mục của riêng bạn. + Kết nối tài khoản hoặc chọn plugin danh mục của riêng bạn. Tìm kiếm và phát Tìm bất kỳ bài hát, album hay nghệ sĩ nào rồi nhấn phát. Nghe ngoại tuyến diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index f5907323..2f59a04d 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -382,7 +382,7 @@ 如何使用 Spotube 只要四個簡單步驟,就能開始聆聽。 登入 - 連結你的 Spotify 帳戶,或選擇你自己的目錄外掛程式。 + 連結你的帳戶,或選擇你自己的目錄外掛程式。 搜尋並播放 找到任何歌曲、專輯或藝人,然後按下播放。 離線聆聽 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index d154c6a2..774ab6ef 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -382,7 +382,7 @@ 如何使用 Spotube 只需四个简单步骤,即可开始聆听。 登录 - 连接你的 Spotify 帐号,或选择你自己的曲库插件。 + 连接你的帐号,或选择你自己的曲库插件。 搜索并播放 查找任意歌曲、专辑或艺人,然后点击播放。 离线收听 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a5481e2e..cddab9ea 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -404,7 +404,7 @@ How to use Spotube Four quick steps and you’re listening. Sign in - Connect your Spotify account, or pick your own catalog plugin. + Connect your account, or pick your own catalog plugin. Search and play Find any song, album, or artist, then press play. Listen offline diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt index 4607265d..785657bf 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt @@ -30,7 +30,7 @@ import platform.WebKit.WKWebViewConfiguration * Mobile Safari's exact User-Agent on this device. * * WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`, - * which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went + * which sites like accounts.greedygreencorp.com treat as an embedded browser ("Oops, something went * wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26 * reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application * name and reports `navigator.userAgent`, which is cached for every web view. diff --git a/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt index 10e45bc6..bd012db7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt @@ -28,6 +28,6 @@ data class ScrobbleTrack( val trackName: String, val artistName: String, val albumName: String?, - val streamingProvider: String, // e.g. "spotify", "apple_music" etc + val streamingProvider: String, // e.g. "greedy_green_corp", "apple_music" etc val durationMs: Long? = null, ) \ No newline at end of file From 14bc27965363fc3f45fd65197f1d9a63861dc737 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Oct 2026 11:10:25 +0600 Subject: [PATCH 118/132] fix(ios): crashing on search screen --- .../spotube/modules/search/SearchScreen.kt | 88 +++++++++---------- .../modules/search/SearchScreenViewModel.kt | 20 ++++- 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt index 03227191..fdcdd734 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt @@ -18,15 +18,6 @@ package org.spotube.spotube.modules.search -import org.spotube.millennium.composables.MillenniumMarqueeProgress -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumListTile -import org.spotube.millennium.composables.MillenniumAutocomplete -import org.spotube.millennium.composables.MillenniumText -import org.spotube.millennium.composables.MillenniumTextField -import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -60,9 +51,6 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.rememberTextFieldState - -import org.spotube.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -77,17 +65,32 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text import com.composeunstyled.currentWindowWidthBreakpoint +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumMarqueeProgress +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.theme.MillenniumTheme import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import org.spotube.spotube.Platform import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.jam.JamRole @@ -108,6 +111,7 @@ import org.spotube.spotube.core.ui.component.UserCard import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.spotube.spotube.core.ui.component.dragScrollable import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.getPlatform import org.spotube.spotube.isDesktop import org.spotube.spotube.modules.blacklist.BlacklistRepository @@ -119,46 +123,38 @@ import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken import org.spotube.spotube.resources.iconsax.IconsaxTrash import org.spotube.spotube.resources.iconsax.InconsaxClock -import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.search_clear_history -import spotube.composeapp.generated.resources.search_placeholder import spotube.composeapp.generated.resources.library_tab_albums import spotube.composeapp.generated.resources.library_tab_artists import spotube.composeapp.generated.resources.library_tab_playlists -import spotube.composeapp.generated.resources.search_results_title -import spotube.composeapp.generated.resources.search_no_search_types -import spotube.composeapp.generated.resources.search_start_typing +import spotube.composeapp.generated.resources.search_clear_history +import spotube.composeapp.generated.resources.search_loading_albums +import spotube.composeapp.generated.resources.search_loading_artists +import spotube.composeapp.generated.resources.search_loading_playlists +import spotube.composeapp.generated.resources.search_loading_tracks +import spotube.composeapp.generated.resources.search_loading_users +import spotube.composeapp.generated.resources.search_no_albums +import spotube.composeapp.generated.resources.search_no_artists +import spotube.composeapp.generated.resources.search_no_playlists import spotube.composeapp.generated.resources.search_no_results +import spotube.composeapp.generated.resources.search_no_search_types +import spotube.composeapp.generated.resources.search_no_users +import spotube.composeapp.generated.resources.search_placeholder +import spotube.composeapp.generated.resources.search_results_title +import spotube.composeapp.generated.resources.search_searching import spotube.composeapp.generated.resources.search_section_tracks import spotube.composeapp.generated.resources.search_section_users -import spotube.composeapp.generated.resources.search_start_typing_tracks -import spotube.composeapp.generated.resources.search_loading_tracks -import spotube.composeapp.generated.resources.search_no_albums -import spotube.composeapp.generated.resources.search_loading_albums -import spotube.composeapp.generated.resources.search_no_artists -import spotube.composeapp.generated.resources.search_loading_artists -import spotube.composeapp.generated.resources.search_no_playlists -import spotube.composeapp.generated.resources.search_loading_playlists -import spotube.composeapp.generated.resources.search_no_users -import spotube.composeapp.generated.resources.search_loading_users import spotube.composeapp.generated.resources.search_see_all -import spotube.composeapp.generated.resources.search_searching -import spotube.composeapp.generated.resources.search_warm_crates +import spotube.composeapp.generated.resources.search_start_typing +import spotube.composeapp.generated.resources.search_start_typing_tracks +import spotube.composeapp.generated.resources.search_tab_all +import spotube.composeapp.generated.resources.search_warm_antennas import spotube.composeapp.generated.resources.search_warm_cosmos +import spotube.composeapp.generated.resources.search_warm_crates +import spotube.composeapp.generated.resources.search_warm_music_gods +import spotube.composeapp.generated.resources.search_warm_records import spotube.composeapp.generated.resources.search_warm_speakers import spotube.composeapp.generated.resources.search_warm_vinyl -import spotube.composeapp.generated.resources.search_warm_music_gods -import spotube.composeapp.generated.resources.search_warm_antennas -import spotube.composeapp.generated.resources.search_warm_records -import spotube.composeapp.generated.resources.search_tab_all -import org.jetbrains.compose.resources.StringResource -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.spotube.millennium.adaptive.WindowBreakpoints private val GridMinCellSize = 180.dp @@ -183,7 +179,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) } var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } - var currentUserId by remember { mutableStateOf(null) } + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle( emptyList() @@ -207,10 +203,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } } - LaunchedEffect(Unit) { - currentUserId = libraryRepository.currentUser()?.id - } - val focusManager = LocalFocusManager.current fun playSingleTrack(track: MetadataTrack) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt index d1a8d836..be097f37 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt @@ -30,6 +30,9 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupport import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent +import org.spotube.spotube.modules.library.LibraryRepository import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -81,7 +84,8 @@ data class SearchScreenState( class SearchScreenViewModel( private val repository: SearchRepository, private val savedTracksRepository: SavedTracksRepository, - private val database: Database) : ViewModel() { + private val libraryRepository: LibraryRepository, + private val database: Database) : ViewModel(), KoinComponent { companion object { private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches") private val SEARCH_TYPES_ORDER = listOf( @@ -97,11 +101,16 @@ class SearchScreenViewModel( private const val ALL_ROW_PAGE_SIZE = 12 } + private val logger by injectLogger() + private val json = Json { ignoreUnknownKeys = true } private val _state = MutableStateFlow(SearchScreenState()) val state: StateFlow = _state.asStateFlow() + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + private var queryJob: Job? = null init { @@ -113,6 +122,15 @@ class SearchScreenViewModel( } loadSupportedSearchTypes() + viewModelScope.launch { loadCurrentUser() } + } + + private suspend fun loadCurrentUser() { + runCatching { + _currentUserId.value = libraryRepository.currentUser()?.id + }.onFailure { e -> + logger.e(e) { "Failed to load current user" } + } } fun onQueryChange(query: String) { From 2aaa0db81e832c946fb2c6f150d3beed5041f4f5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Oct 2026 11:50:27 +0600 Subject: [PATCH 119/132] fix(broker): update default broker settings and improve connection error handling --- .../commonMain/composeResources/values/strings.xml | 1 + .../org/spotube/spotube/core/jam/JamRoomClient.kt | 14 ++++++++++++-- .../spotube/modules/settings/SettingsModels.kt | 6 +++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index cddab9ea..2fbae5e0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -553,6 +553,7 @@ Broker host is empty Connection failed Connection lost + Couldn't reach %1$s:%2$d. Check your network or pick another broker in Settings → Jam. Broker diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt index 85085f0f..07810202 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt @@ -33,12 +33,14 @@ import org.spotube.spotube.modules.settings.JamBroker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -47,6 +49,7 @@ import kotlinx.serialization.json.Json import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.jam_error_broker_host_empty +import spotube.composeapp.generated.resources.jam_error_broker_unreachable import spotube.composeapp.generated.resources.jam_error_connection_failed import spotube.composeapp.generated.resources.jam_error_connection_lost import spotube.composeapp.generated.resources.jam_error_no_broker @@ -135,8 +138,15 @@ class JamRoomClient : KoinComponent { val mqtt = startClient(broker, clientId) client = mqtt - withTimeout(broker.connectionTimeoutSeconds.seconds) { - mqtt.awaitConnectivity() + try { + withTimeout(broker.connectionTimeoutSeconds.seconds) { + // connectionState replays its current value, unlike awaitConnectivity's + // broadcast flow, which loses a CONNACK that lands before it is collected. + mqtt.connectionState.first { it is ConnectionState.Connected } + } + } catch (e: TimeoutCancellationException) { + throw IllegalStateException( + getString(Res.string.jam_error_broker_unreachable, broker.host, broker.port), e) } _connectionError.value = null log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index f22688a3..90f87e3e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -98,9 +98,9 @@ data class UserSettings( @Serializable data class JamBroker( val name: String = "", - val host: String = "test.mosquitto.org", - val port: Int = 1883, - val useTls: Boolean = false, + val host: String = "broker.hivemq.com", + val port: Int = 8883, + val useTls: Boolean = true, val username: String? = null, val password: String? = null, val clientIdPrefix: String = "spotube", From c058074cff042a38389853d88abe46d20d9c3902 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Oct 2026 13:08:34 +0600 Subject: [PATCH 120/132] fix(plugin): update default selected plugins to use built-in plugins with distinct abilities --- .../org/spotube/spotube/modules/plugin/PluginManager.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 13d1f380..9977f96c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -149,8 +149,11 @@ class PluginManager( val state: StateFlow = database.settingsDataStore.data .map { p -> val json = p[DatabaseKeys.PLUGINS_STATE_KEY] - val defaultSelectedPlugins = mapOf( - PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN) + // Each category starts out with the built-in plugin that provides it. + val defaultSelectedPlugins = BUILT_IN_PLUGINS + .flatMap { plugin -> plugin.abilities.map { it to plugin } } + .distinctBy { it.first } + .toMap() if (json == null) { PluginManagerStates.Data( plugins = BUILT_IN_PLUGINS, From bba725a1398347a9e7466c34305599aa8e40a20c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Oct 2026 13:35:39 +0600 Subject: [PATCH 121/132] fix(android): fully close the app on swipe-away and avoid duplicate cookie DataStore Initialize NewPipeDownloader once per process so recreating the Activity no longer opens a second DataStore on spotube_cookies.preferences_pb. Kill the process after PlaybackService shuts down on task removal so the proxy server, MQTT client and singletons don't linger in the background. --- .../spotube/spotube/core/newpipe/NewPipeDownloader.kt | 10 ++++++++-- .../org/spotube/spotube/media/PlaybackService.kt | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt index fc0fa47e..5e3def2b 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt @@ -82,10 +82,16 @@ class NewPipeDownloader(private val cookieJar: PersistentCookieJar) : Downloader companion object { private var instance: NewPipeDownloader? = null + /** + * Safe to call on every Activity creation: the process (and so this singleton) outlives + * the Activity, and a second [PersistentCookieJar] would open a second DataStore on the + * same cookies file, which DataStore rejects. + */ + @Synchronized fun init(paths: Paths) { + if (instance != null) return val cookieJar = PersistentCookieJar(paths) - instance = NewPipeDownloader(cookieJar) - NewPipe.init(instance) + instance = NewPipeDownloader(cookieJar).also { NewPipe.init(it) } } } } diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt index bc3fdaf1..74df39e7 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -23,6 +23,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import android.os.Build +import android.os.Process import android.util.Log import androidx.annotation.OptIn import androidx.core.app.NotificationCompat @@ -61,6 +62,8 @@ import spotube.composeapp.generated.resources.notification_playback_channel_name @OptIn(UnstableApi::class) class PlaybackService : MediaLibraryService(), KoinComponent { + private var taskRemoved = false + private val audioPlayer = inject().value as AudioPlayer private val audioPlayerQueue: AudioPlayerQueue by inject() @@ -161,9 +164,13 @@ class PlaybackService : MediaLibraryService(), KoinComponent { removeSession(librarySession) librarySession.release() super.onDestroy() + // Swiping the app out of recents should fully close it: Android otherwise keeps this + // process cached with the proxy server, MQTT client and every singleton still alive. + if (taskRemoved) Process.killProcess(Process.myPid()) } override fun onTaskRemoved(rootIntent: Intent?) { + taskRemoved = true pauseAllPlayersAndStopSelf() } From 9f4ce115093eb1db761d1e327313565533aac771 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Oct 2026 14:16:51 +0600 Subject: [PATCH 122/132] fix(player): smooth the expanded player swipe Read sheet progress in draw/derived scopes instead of composition so a drag no longer recomposes the player every frame, keep the expanded player, floating player and bottom bar composed once shown instead of building them mid-drag, and settle with a spring that carries fling velocity. Add a drag test for CompactPlayerOverlay. --- .../spotube/spotube/modules/shell/AppShell.kt | 120 ++++++++++++------ .../shell/CompactPlayerOverlayDragTest.kt | 113 +++++++++++++++++ 2 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt index 484fe48b..b8f3d388 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt @@ -32,17 +32,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Spring import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.dp @@ -299,6 +305,15 @@ internal fun CompactPlayerOverlay( onAlternativeSource: () -> Unit, onExpandLyrics: () -> Unit, floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) }, + expandedPlayer: @Composable (modifier: Modifier, onCollapse: () -> Unit) -> Unit = { modifier, onCollapse -> + AppExpandedPlayer( + modifier = modifier, + onCollapse = onCollapse, + onQueue = onQueue, + onAlternativeSource = onAlternativeSource, + onExpandLyrics = onExpandLyrics + ) + }, bottomBar: @Composable (Modifier) -> Unit = { AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it) }, @@ -325,16 +340,39 @@ internal fun CompactPlayerOverlay( val sheetState = key(peekHeight) { rememberBottomSheetState( initialDetent = partialDetent, - detents = listOf(partialDetent, SheetDetent.FullyExpanded) + detents = listOf(partialDetent, SheetDetent.FullyExpanded), + // A spring carries the fling velocity into the settle, so releasing mid-drag + // glides to the nearest detent instead of stalling and then easing like a tween. + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow) ) } - val progress = if (!sheetState.offset.isFinite() || - (sheetState.isIdle && sheetState.currentDetent == partialDetent) - ) 0f else runCatching { - sheetState.progress(partialDetent, SheetDetent.FullyExpanded) - }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f - val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f)) + // Read only inside draw/derived scopes: the sheet offset changes every drag frame, and + // reading it in composition would recompose the whole player on each one. + val progress by remember(sheetState, partialDetent) { + derivedStateOf { + val value = if (!sheetState.offset.isFinite() || + (sheetState.isIdle && sheetState.currentDetent == partialDetent) + ) 0f else runCatching { + sheetState.progress(partialDetent, SheetDetent.FullyExpanded) + }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f + value.coerceIn(0f, 1f) + } + } + // Keyed on the sheet state: it is recreated when the peek height changes, and a derived + // value left reading the previous sheet's progress would never update again. + val expandedPlayerVisible by remember(sheetState) { + derivedStateOf { viewModel.compactSheetUiState(progress).showExpandedPlayer } + } + val floatingPlayerVisible by remember(sheetState) { + derivedStateOf { viewModel.compactSheetUiState(progress).floatingPlayerAlpha > 0.01f } + } + // Once shown, keep the expanded player composed: composing it (view models, lyrics, cover + // art) on the first drag frame of every gesture is the hitch the user feels as lag. + var expandedPlayerComposed by remember { mutableStateOf(false) } + if (expandedPlayerVisible) expandedPlayerComposed = true Box(modifier = Modifier.fillMaxSize()) { content() @@ -345,44 +383,52 @@ internal fun CompactPlayerOverlay( ) { Sheet(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) { - if (uiState.showExpandedPlayer) { - AppExpandedPlayer( - modifier = Modifier + if (expandedPlayerComposed) { + expandedPlayer( + Modifier .fillMaxSize() - .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, - onCollapse = { - scope.launch { sheetState.animateTo(partialDetent) } - }, - onQueue = onQueue, - onAlternativeSource = onAlternativeSource, - onExpandLyrics = onExpandLyrics + .graphicsLayer { + alpha = viewModel.compactSheetUiState(progress).expandedPlayerAlpha + }, + { scope.launch { sheetState.animateTo(partialDetent) } } ) } - if (uiState.floatingPlayerAlpha > 0.01f) { - Column(modifier = Modifier.fillMaxWidth()) { - floatingPlayer( - Modifier - .graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }) - } + // Always composed: building it (view model, cover art, player state) only once + // progress drops below the fade-out point stalls the collapse mid-drag. + Column(modifier = Modifier.fillMaxWidth()) { + floatingPlayer( + Modifier + .zeroSizeUnless(floatingPlayerVisible) + .graphicsLayer { + alpha = viewModel.floatingPlayerAlpha(progress) + }) } } } } - if (uiState.floatingPlayerAlpha > 0.01f) { - bottomBar( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .onSizeChanged { size -> - viewModel.onBottomBarMeasured(with(density) { size.height.toDp() }) - } - .graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }) - } + bottomBar( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .zeroSizeUnless(floatingPlayerVisible) + .onSizeChanged { size -> + viewModel.onBottomBarMeasured(with(density) { size.height.toDp() }) + } + .graphicsLayer { + alpha = viewModel.floatingPlayerAlpha(progress) + }) } } + +/** + * Keeps the content composed but takes it out of layout, drawing and hit testing while it is + * fully faded out, so it neither steals touches from what is above it nor costs a re-composition + * when it comes back. + */ +private fun Modifier.zeroSizeUnless(visible: Boolean): Modifier = + if (visible) this else layout { measurable, constraints -> + measurable.measure(constraints) + layout(0, 0) {} + } diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt new file mode 100644 index 00000000..d11f6e89 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt @@ -0,0 +1,113 @@ +package org.spotube.spotube.modules.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalComposeUiApi::class) +class CompactPlayerOverlayDragTest { + @Test + fun draggingTheSheetUpShowsTheExpandedPlayer() { + var expandedComposed = false + var floatingCompositions = 0 + var floatingPlaced = false + val width = 400 + val height = 800 + ImageComposeScene(width, height) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES) + val navigator = remember(navigation) { Navigator(navigation) } + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigation, + viewModel = remember { AppShellViewModel() }, + onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {}, + floatingPlayer = { modifier -> + DisposableEffect(Unit) { + floatingCompositions++ + onDispose {} + } + Box( + modifier.fillMaxWidth().height(64.dp).background(Color.Gray) + .onGloballyPositioned { floatingPlaced = true }) + }, + expandedPlayer = { modifier, _ -> + DisposableEffect(Unit) { + expandedComposed = true + onDispose { expandedComposed = false } + } + Box(modifier.fillMaxSize().background(Color.Red)) + }, + bottomBar = { modifier -> + Box(modifier.fillMaxWidth().height(56.dp).background(Color.Blue)) + } + ) { Box(Modifier.fillMaxSize()) } + } + }.use { scene -> + var t = 0L + fun frame() { scene.render(t); t += 16_000_000L } + repeat(10) { frame() } + assertTrue(floatingPlaced, "floating player should show while collapsed") + assertFalse(expandedComposed, "expanded player should not be composed while collapsed") + + // grab the floating player (just above the bottom bar) and drag to the top + val startY = height - 120f + 20f + val start = Offset(width / 2f, startY) + scene.sendPointerEvent(PointerEventType.Press, start, type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(5) { frame() } + var y = startY + repeat(30) { + y -= (startY - 40f) / 30f + scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L) + frame() + } + scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(120) { frame() } + + assertTrue(expandedComposed, "expanded player should be composed after dragging up") + // out of layout while the expanded player covers it, but never re-composed + floatingPlaced = false + repeat(5) { frame() } + assertFalse(floatingPlaced, "floating player should be out of layout when fully expanded") + + // drag back down: the floating player must return without being composed again + val downStart = Offset(width / 2f, 60f) + scene.sendPointerEvent(PointerEventType.Press, downStart, type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(5) { frame() } + var dy = downStart.y + repeat(30) { + dy += (height - 160f) / 30f + scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L) + frame() + } + scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(120) { frame() } + assertTrue(floatingPlaced, "floating player should be back after collapsing") + assertTrue(floatingCompositions == 1, "floating player was composed $floatingCompositions times, expected 1") + } + } +} From e04b3414e04165df0f5807616d3b94ffb9792214 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Oct 2026 14:43:29 +0600 Subject: [PATCH 123/132] chore(assets): refresh screenshots, store graphics and Steam hero - Add new desktop and iOS screenshots under assets/screenshots, named by screen, theme and form factor, and optimized with pngquant + oxipng - Add feature graphics under assets/feature-graphics: Play Store feature graphic, README hero and mobile strip, and phone/desktop store shots - Use them in the README, Flathub metainfo (plain screenshots) and F-Droid metadata (phone screenshots + featureGraphic) - Replace the Steam library hero with one made from the new UI - Remove the old desktop and mobile screenshots --- README.md | 4 +-- .../branding/mobile-screenshots/android-1.jpg | Bin 46049 -> 0 bytes .../branding/mobile-screenshots/android-2.jpg | Bin 40774 -> 0 bytes .../branding/mobile-screenshots/android-3.jpg | Bin 23974 -> 0 bytes .../branding/mobile-screenshots/android-4.jpg | Bin 33795 -> 0 bytes .../branding/mobile-screenshots/android-5.jpg | Bin 34806 -> 0 bytes .../branding/mobile-screenshots/combined.jpg | Bin 176064 -> 0 bytes assets/branding/spotube-hero-banner.png | Bin 789046 -> 847594 bytes assets/branding/spotube-screenshot.png | Bin 789127 -> 0 bytes .../feature-graphics/play-feature-graphic.png | Bin 0 -> 268185 bytes assets/feature-graphics/readme-hero.png | Bin 0 -> 583359 bytes assets/feature-graphics/readme-mobile.png | Bin 0 -> 324744 bytes .../store-desktop-01-home.png | Bin 0 -> 625878 bytes .../store-desktop-02-lyrics.png | Bin 0 -> 215365 bytes .../store-desktop-03-playlists.png | Bin 0 -> 641744 bytes .../store-desktop-04-search.png | Bin 0 -> 273161 bytes .../store-desktop-05-local-tracks.png | Bin 0 -> 179024 bytes .../store-desktop-06-group-jam.png | Bin 0 -> 148624 bytes .../feature-graphics/store-mobile-01-home.png | Bin 0 -> 296975 bytes .../store-mobile-02-player.png | Bin 0 -> 127460 bytes .../store-mobile-03-library.png | Bin 0 -> 170775 bytes .../store-mobile-04-devices.png | Bin 0 -> 114912 bytes .../store-mobile-05-group-jam.png | Bin 0 -> 109174 bytes assets/screenshots/devices-desktop-dark.png | Bin 0 -> 456393 bytes assets/screenshots/devices-mobile-light.png | Bin 0 -> 66955 bytes .../screenshots/downloads-desktop-light.png | Bin 0 -> 433834 bytes assets/screenshots/group-jam-desktop-dark.png | Bin 0 -> 439081 bytes assets/screenshots/group-jam-mobile-light.png | Bin 0 -> 47926 bytes assets/screenshots/home-desktop-dark.png | Bin 0 -> 983377 bytes assets/screenshots/home-desktop-light.png | Bin 0 -> 983438 bytes assets/screenshots/home-mobile-dark.png | Bin 0 -> 438894 bytes assets/screenshots/home-mobile-light.png | Bin 0 -> 441040 bytes assets/screenshots/home-player-dark.png | Bin 0 -> 131723 bytes assets/screenshots/home-player-light.png | Bin 0 -> 136157 bytes assets/screenshots/library-mobile-light.png | Bin 0 -> 169312 bytes .../screenshots/local-tracks-desktop-dark.png | Bin 0 -> 436927 bytes .../local-tracks-desktop-light.png | Bin 0 -> 442551 bytes assets/screenshots/lyrics-desktop-dark.png | Bin 0 -> 545912 bytes assets/screenshots/lyrics-desktop-light.png | Bin 0 -> 577627 bytes assets/screenshots/playlists-desktop-dark.png | Bin 0 -> 918322 bytes .../screenshots/playlists-desktop-light.png | Bin 0 -> 922966 bytes assets/screenshots/search-desktop-dark.png | Bin 0 -> 551149 bytes assets/screenshots/search-desktop-light.png | Bin 0 -> 545535 bytes assets/screenshots/search-mobile-light.png | Bin 0 -> 146342 bytes metadata/en-US/images/featureGraphic.png | Bin 0 -> 268185 bytes metadata/en-US/images/phoneScreenshots/1.png | Bin 0 -> 296975 bytes metadata/en-US/images/phoneScreenshots/2.png | Bin 0 -> 127460 bytes metadata/en-US/images/phoneScreenshots/3.png | Bin 0 -> 170775 bytes metadata/en-US/images/phoneScreenshots/4.png | Bin 0 -> 114912 bytes metadata/en-US/images/phoneScreenshots/5.png | Bin 0 -> 109174 bytes .../images/phoneScreenshots/android-1.jpg | Bin 46049 -> 0 bytes .../images/phoneScreenshots/android-2.jpg | Bin 40774 -> 0 bytes .../images/phoneScreenshots/android-3.jpg | Bin 23974 -> 0 bytes .../images/phoneScreenshots/android-4.jpg | Bin 33795 -> 0 bytes .../images/phoneScreenshots/android-5.jpg | Bin 34806 -> 0 bytes metadata/tr/images/featureGraphic.png | Bin 0 -> 268185 bytes metadata/tr/images/phoneScreenshots/1.png | Bin 0 -> 296975 bytes metadata/tr/images/phoneScreenshots/2.png | Bin 0 -> 127460 bytes metadata/tr/images/phoneScreenshots/3.png | Bin 0 -> 170775 bytes metadata/tr/images/phoneScreenshots/4.png | Bin 0 -> 114912 bytes metadata/tr/images/phoneScreenshots/5.png | Bin 0 -> 109174 bytes .../tr/images/phoneScreenshots/android-1.jpg | Bin 46049 -> 0 bytes .../tr/images/phoneScreenshots/android-2.jpg | Bin 40774 -> 0 bytes .../tr/images/phoneScreenshots/android-3.jpg | Bin 23974 -> 0 bytes .../tr/images/phoneScreenshots/android-4.jpg | Bin 33795 -> 0 bytes .../tr/images/phoneScreenshots/android-5.jpg | Bin 34806 -> 0 bytes .../org.spotube.spotube.metainfo.xml.mustache | 24 ++++++++++++++++-- 67 files changed, 24 insertions(+), 4 deletions(-) delete mode 100644 assets/branding/mobile-screenshots/android-1.jpg delete mode 100644 assets/branding/mobile-screenshots/android-2.jpg delete mode 100644 assets/branding/mobile-screenshots/android-3.jpg delete mode 100644 assets/branding/mobile-screenshots/android-4.jpg delete mode 100644 assets/branding/mobile-screenshots/android-5.jpg delete mode 100644 assets/branding/mobile-screenshots/combined.jpg delete mode 100644 assets/branding/spotube-screenshot.png create mode 100644 assets/feature-graphics/play-feature-graphic.png create mode 100644 assets/feature-graphics/readme-hero.png create mode 100644 assets/feature-graphics/readme-mobile.png create mode 100644 assets/feature-graphics/store-desktop-01-home.png create mode 100644 assets/feature-graphics/store-desktop-02-lyrics.png create mode 100644 assets/feature-graphics/store-desktop-03-playlists.png create mode 100644 assets/feature-graphics/store-desktop-04-search.png create mode 100644 assets/feature-graphics/store-desktop-05-local-tracks.png create mode 100644 assets/feature-graphics/store-desktop-06-group-jam.png create mode 100644 assets/feature-graphics/store-mobile-01-home.png create mode 100644 assets/feature-graphics/store-mobile-02-player.png create mode 100644 assets/feature-graphics/store-mobile-03-library.png create mode 100644 assets/feature-graphics/store-mobile-04-devices.png create mode 100644 assets/feature-graphics/store-mobile-05-group-jam.png create mode 100644 assets/screenshots/devices-desktop-dark.png create mode 100644 assets/screenshots/devices-mobile-light.png create mode 100644 assets/screenshots/downloads-desktop-light.png create mode 100644 assets/screenshots/group-jam-desktop-dark.png create mode 100644 assets/screenshots/group-jam-mobile-light.png create mode 100644 assets/screenshots/home-desktop-dark.png create mode 100644 assets/screenshots/home-desktop-light.png create mode 100644 assets/screenshots/home-mobile-dark.png create mode 100644 assets/screenshots/home-mobile-light.png create mode 100644 assets/screenshots/home-player-dark.png create mode 100644 assets/screenshots/home-player-light.png create mode 100644 assets/screenshots/library-mobile-light.png create mode 100644 assets/screenshots/local-tracks-desktop-dark.png create mode 100644 assets/screenshots/local-tracks-desktop-light.png create mode 100644 assets/screenshots/lyrics-desktop-dark.png create mode 100644 assets/screenshots/lyrics-desktop-light.png create mode 100644 assets/screenshots/playlists-desktop-dark.png create mode 100644 assets/screenshots/playlists-desktop-light.png create mode 100644 assets/screenshots/search-desktop-dark.png create mode 100644 assets/screenshots/search-desktop-light.png create mode 100644 assets/screenshots/search-mobile-light.png create mode 100644 metadata/en-US/images/featureGraphic.png create mode 100644 metadata/en-US/images/phoneScreenshots/1.png create mode 100644 metadata/en-US/images/phoneScreenshots/2.png create mode 100644 metadata/en-US/images/phoneScreenshots/3.png create mode 100644 metadata/en-US/images/phoneScreenshots/4.png create mode 100644 metadata/en-US/images/phoneScreenshots/5.png delete mode 100644 metadata/en-US/images/phoneScreenshots/android-1.jpg delete mode 100644 metadata/en-US/images/phoneScreenshots/android-2.jpg delete mode 100644 metadata/en-US/images/phoneScreenshots/android-3.jpg delete mode 100644 metadata/en-US/images/phoneScreenshots/android-4.jpg delete mode 100644 metadata/en-US/images/phoneScreenshots/android-5.jpg create mode 100644 metadata/tr/images/featureGraphic.png create mode 100644 metadata/tr/images/phoneScreenshots/1.png create mode 100644 metadata/tr/images/phoneScreenshots/2.png create mode 100644 metadata/tr/images/phoneScreenshots/3.png create mode 100644 metadata/tr/images/phoneScreenshots/4.png create mode 100644 metadata/tr/images/phoneScreenshots/5.png delete mode 100644 metadata/tr/images/phoneScreenshots/android-1.jpg delete mode 100644 metadata/tr/images/phoneScreenshots/android-2.jpg delete mode 100644 metadata/tr/images/phoneScreenshots/android-3.jpg delete mode 100644 metadata/tr/images/phoneScreenshots/android-4.jpg delete mode 100644 metadata/tr/images/phoneScreenshots/android-5.jpg diff --git a/README.md b/README.md index 2cc7453b..d658d3ba 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm --- -![Spotube Desktop](assets/branding/spotube-screenshot.png) +![Spotube Desktop](assets/feature-graphics/readme-hero.png) -![Spotube Mobile](assets/branding/mobile-screenshots/combined.jpg) +![Spotube Mobile](assets/feature-graphics/readme-mobile.png) diff --git a/assets/branding/mobile-screenshots/android-1.jpg b/assets/branding/mobile-screenshots/android-1.jpg deleted file mode 100644 index 16debbd7440a920605d8d87367f9792e647f777e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46049 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 Fe*tSyv2p+a diff --git a/assets/branding/mobile-screenshots/android-2.jpg b/assets/branding/mobile-screenshots/android-2.jpg deleted file mode 100644 index d594227eade8eed6ea2e2fced0386a42272a16c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40774 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

$DhB^z;-@X-!Dw6>Z> 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 RWdDEosQt62Fz%Px{{vkK=?VY< diff --git a/assets/branding/mobile-screenshots/android-5.jpg b/assets/branding/mobile-screenshots/android-5.jpg deleted file mode 100644 index 29be481b6b9be5a40d63daf4abddbeb734b6e367..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34806 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 ^pdlnu+GBNE_?GiO+^K+b6E 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 Wzm{MY^-6x)dYd%%U)}!N{l5Sx6|mL- diff --git a/assets/branding/mobile-screenshots/combined.jpg b/assets/branding/mobile-screenshots/combined.jpg deleted file mode 100644 index 03740580b866f4ee4722aefdaac12c71315df42d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176064 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

G|Db&w8Y`w0!JE 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

esho1ed=gnxGvv|KP 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

I+ewKozYk!&|C# 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..9e132a08dcf8e893eea99068400db47b934dca8f GIT binary patch literal 20654 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..d67288fb1f7b181bec2cf49024c196701e8f7f02 GIT binary patch literal 22211 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 WE+Q_m>7mj90000 + + + + Spotube Nightly + diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 4e8d485b..5a38d341 100644 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -15,7 +15,8 @@ ], "idiom" : "universal", "platform" : "ios", - "size" : "1024x1024" + "size" : "1024x1024", + "filename" : "app-icon-1024-dark.png" }, { "appearances" : [ @@ -26,7 +27,8 @@ ], "idiom" : "universal", "platform" : "ios", - "size" : "1024x1024" + "size" : "1024x1024", + "filename" : "app-icon-1024-tinted.png" } ], "info" : { diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..dbf53a467a85a136b0d400baa91a526bcb1a8f70 GIT binary patch literal 182897 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 DLeM15 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png new file mode 100644 index 0000000000000000000000000000000000000000..eb3721eb76b3ece0540a4c95d6992f4d94f4b9fb GIT binary patch literal 87930 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 KtLVnVm;Vp_P}P_K literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png index 53fc536fb9ac5c1dbb27c7e1da13db3760070a11..dbf53a467a85a136b0d400baa91a526bcb1a8f70 100644 GIT binary patch literal 182897 zcmbTd2Rxk5_BgzR5TZm+ltt7;l<0MZh)xhh^d3D3tCK_v(R+^`y{z7&m+0MMm8gr= z+p_*E-}}4wd++c5-}m#r!{>SCoGH)DX>;bx?AO;SvIKaK@Bjb+fxO&HbpQYhoy7v+ zVxnLBzD*MV3;;m><%>6Nb34~tCe(cuB?PiBEvEcF(y;;iba&A`-zL9f5}?r1LHTH$ zkLy@}a`w#5Ledpis{{!Gk8U0W8zrQ7^2>zP2UpWq%LweMJCUXD)b|TTCyE~i& zpfXVTZ|x*~MXrGs|Fz_`6#!tga4S<7v;Y9Ht>nKG^?$W779;$FBM$WY?=`WMMgD5! z|M2gPaj$LBAP_lkCU%=aCtMPG^wU3t55Pu%xv%lT?N-Kb$%W8GKmOkn{V%isO2p4@ z|Axk$)Ij&e^r!cK>-5D98iXBu{Y*p-*cZ5^z<)`fKmRq0WXIdW@79=rEDJwXImwxS z`^LXDlqBB81nkRW{7ujzz5iBSl5Oz6P|n{j^7Qc1*QkF~qyH}>_>Ug`6U5c>x<#FP2hhF6 z`X3YVr_!-!2|TOle&&C+_$Q2;rHamv{GBgS3%Iw%zCyQR{I4Ws`oQQC|6edVNB;(f zLI3ODQ2zuZ6Z2W+{Qe34?~I=j5D!=2`~NNgT;JEndPDM8L0i=$w8}b&nf!y=P5D|xj)eVmM+OiIJ(4dpM+ik;75<~@3s@yoAsZa{AcE~IUZKg zeeWD-gmWmG$UA@2pTBkUx2zwxOrZQ9!}0f-{i%SZ z|M#)_tKD+_U$c6Vj-Ke!1}dx^(OkOhjs}=+TeTobp%PSf88@4mIRWI}YId zI(QFs`MA3djryk&5kF~1gDCxsU#{|)oe4f3rQ7aj&Woim0^RiUk^m)iVI-P+OBh%6 zgyWtPF}q{TyCIb~(!fJkS&~DC)F)`_?>q0`(qL(InH|QF+DbhRmCd^GWb|xA3?0e7 z!|=L31@(88V*4V(=EwKS;e9i{O5_-8Xz?Ne?6YF{?zCmV<<=0%YilqaRjLKF$kL+~ zBSaW*^{Mmh3<^D>eV2@kyS(B(K0A$(*xxn1j0 z9G>f4qsUOyJWWvdEMhrA&PIao*}iwJyC}F4IG`!Pv?qn82fFwDQCz@xP*LCplIF}; z?CDa3;bLj(MQLy_m-q}cEeP1>dcMy43kYmDZ%My{O2z=-u5sRywhg2!d;}o@Ai-76 zz7843ftifxk7MBi_Njfvs7U+}$keL@auiGxHGlEMplEl-f&KF4{fj<{EU*+>PXV{$ z;kzACC;D{rN+09tco68_?qx66wZ)nI-*?WjZSgX=tJd?m!k`{%3+Kol5+_Uvw1 z23Rs2pSiLJyCdC=*M>b3uXyQpf_7vtKysGDp!r)ndP~Fo3|qa+RSd?P36bXOwlJy} zTzEyV0RRQGFR&kUcyP5P3yEiIM03Aj^%tR_&ET7bkhF}JuRE-B8%M1I80AQx1aE}h;X><* ztU&B?$3uW*Bme`@HXNqon{zr0N>x1a{7l}MpIS<&25kwX4_3PQ5CSZ_9p!Ji$nJwXbj_AhoS7ciG4YuMtR5bX48aq z87XxUQS+GU2(A5pX8mP7qUPJOL{(V`Eav_!k;8z;G}qw<;gkJVm!UY=_$DkPY{nsy zYUYr0Zg@o1=RNyRSuV70-KgKeN4HN=|B0~kT5H5#1Xj1*v;?OjB3Jf3dycOQbEqp$ z9QeqNHFEY@%fh{{Xnk5GVJ-0Ot7dV3=ur=X)4n6<5#TSUZDSO29X?KuGKLNU=RbHo zn~xQJBX~;fpa}uOO=l2gC)ZyHLO?H>@2pX{+1(DCSgH1BD+eZWuXj~{BLmqb240lE zlplD1u?kK>w1`}P%M8dksq~SZ?}OQ>evEdT>FLrxJup2)Wl1FO0sz0z>_+W$OMo1^ z&uXcEuccJ)LIrz4C(HZ8yhZh(_L_`0Vl@c<{-(U;3LrIROPZ7yOvMkj^88k*ZpGoo ztpaAjI}P@qo8AdLAB#KySJ9t~uU>hUinfMa*?=Z}6)=rpYq7qJm6dDeJL00gjXpcG zarj3#wGj+x{kdXggWoPWe!k7*!*=(^`&2Jl!kWC!Xd#U^_OoX!o@cdsZV$iT6c-cW z+3(|!BUe9QZ=wf1g7F=pbp&J2cRx#|Hr_mAcXbmU@bnDv1KB%wf~m%iJiq=VFehwH zpqrt^Mun1JLc@Zgm8r-8NnpvyjjI#=7g1u8TLPke!X;3_R-g9gcA|$}`-6Q$7wHS& z;f;vjyVY9z-^nu^@FX`epVtyn;tSU&OP0=h{m5+omO)QjfELy@0&AbfY%Yv zqVGVH7iv*A*Xyc&&{lf)3qR7?3ROJ>fQj9hocr+XpxE!V?aF_lE=yiG@VvlrB5GL$ zqgNbbwB$+pnkh~r%5|DDZ7Ui@w_tYioiLlG4nbAJP~^pzzK}Y(=~}w|6IzMq>PBA8uY``L=ZU2T5dg)kfHyAzH73 z*0IhnX&ZM>J1lLQ&eiMK^R<6J6+v~)dQlk6xtMA+l35xGF7vRAL3Blu)+Qk&$X&*U z1&(Iu0`HwOFJz{>A2k?-GP)kZT0%wL4jQeb=lZOGv(E8jQ*#Go@W*smfG_uK zU7~$UDnFN!#$tB(631F=;Ho&>(j}vpHk_TTTf0Y5>xp@321u@b$PqFM}i1|TU#o5!H zdzcYvql001Z}Y+chsi)u<3KM>riO%{!P;C!Ihmg~9r9bkQxOyY2< zgyDHtsI~a;U9XE*+0rUE-;dl?{T$4#J4ivHIb3Y}yB0sh8~tY77TsF(i=3f4HAd6s zXI`!sM^+XM1=FpGLt8sm6B#@tZ6NMz^w)0Jr{ka^iUo&y=V9y}`-(wvYA?jZW~=T_ z!hm4chNf4`n}v;o_Jns>O-*>i4p-qpZO0g}v2kiqMSujyV6BGmh-Sg?E|$^Nl>JQa zwr9r*$ebSvHVkm!{8s*&PEi^8p0@Nvjs1^qzXgqP@oSpa7#{)Z=KOX~CzWg{C( zZ5EUg!vu+3Pkj>;2rxvR9q}3tDeVzlU$noe&>y`>t-|JfwcP{XdTy4{b!L$ejnU}U zl4g!+AqWYk?zjW)KS035K5P(-k>`&hpbdbFy8yot3;@+4M(YMmn*?`-hc?fNXyM6p z4iuIK;3(@oTr5j}>R!qO=ty$%5asAdQqtDN@!IlvZkSYE_x#`&4d=0JMtd7YXc1dbpRfa+bBju6ZmXi2J>snm1t9 zZ#U+$WtnioTQ$HPM*^%n5DP9YH=j$uzL%x68@X71s+is)amXXFU{E7%JdYQpNjln2 zFk>XHk^IWeAmaMG)c53#+g8|PQ}UbU%zzkrl$ixMq{$9%t%qu%yKcT)Xi;VR(&JGu zBq@#czH1Zbu>JnWwEf~?qlC{0W81k!Wan=<^vTY;R0Xv3<=HRPVp7W?5xzf~;k;;@ z4jYO6je2{f(kC&F3~ksG_BM??i)INL+J*~6ENoG^8ot>-(mUYd#rHMH!>Ob>O{@GI zGwZkYTqD~ko?+kJ!YNBM3&QkrC_T~UUYk6QpGAOImi!(_Pk!rVv#G$`*33cv5i~XD zCa;I(%hqgl_xREM-(nAi(w!xEB|p9}`RJLMxX}FK*WRLV+u1H-x#Dav!zB(t#0G7s z{b0MSed3%a;GvU6tJ50>?F=Y>{dvQ9I0xyS$4&!^Lxo7`CW)@i7|k^|pYW3-Y^6Y6 zneg0eUGzZOjZzWR;}tR7%y+x-aTqKZ&xVo%b+Vd%!gg-khLB|vX58*LoOEEJ2L z@~|s>w0?68yI1Zg3?9j}$+k7>{vD(!uJ{*0mVnK0lN$o~Ft`zCI?%(Np}L#LaKJ{Zy@b^VTiM`2Cn`Eb)9n1R?5xGXF>(K}`I&W|#a9kPbxY|s;Sa-HI+*&wE0o09HM>}i3t7b=sk7u4z(T%S7)jlP~ zu4Ta~O>;)sSEe}3+kl!aPXPmg7yy4lz`6q9=fzf#%hhQIdtYPjwdc;wMZbiR!R0%H z9TL5a1M#zcW1U|U=`4UIIlxaJhcx?Bm8`1K(TIg{Kt2evhg4a~Go60(IXUmt$E=hv$QLj>y)?&XVp za<-S0fU#^OVe|>EL{JVq!;YGl6hg%>+Y6agFlZ8IEn`9M-vi)R^o}qmsuKP(yw6`v za~{LY3!!n&e-UOX%`WcS#gnJn^T?NQ)0O$13wLgwp8xdQ1E;MN`t@)|X=!cu zmjm&d`|Xz4uN89%l%v=Mt}ASX6L{pi-Yo3>OuGjdpO3LDt;y##zv011WOr799}JMj zd^enryicR5de3i_0~uv%($*1>f4=xYczrqKSGvwOT4C9pw_YlAiT<|dGQSvA5@imn zJ0IYO;)d2;0qI0=yrKbA8?W`1H8quE!ZMqZS$KIhAvyA2O~V(QU0{49=H@Ddn=w|5 z%4Sjdgl)uA8||%?%wv2D!3BiMcNL3QmE|k98tPdieY5k7%V&c5!hc?S2`v5QBPvxs z+U@GuweV^vSQ3RofD1m}(wN74;*!n4zg&A>4fPhf1Sk;}(3K(|3I{_7f=#k7=z@voJ>RU53} zZ-@DBjEm=w0u)*;9XW=2ql79`sj-~uEr7xJBt2qx&p3mf7d+bF&VU8#UXmKq^AE7NK!5mVMnF=oI^3fZLBD%vV6Se5F* zvSE?WJTQwxX}c{F7to$Kt2oDZwsj zV8GBCD&~kKL&v#c%@3i}qH1rj1oS^&3BG>4K77yb-dEFm(w_`d<>-?+waSk;J~0tf z{z~tH;1A&-@F>05r1lp@H=3LmL%~DnHzn(k@CI6FHqzXN8R{xOEPkb=2 zr1Sm|yI;DMLn?Z>+#FL9g4K4&1}K`NI(wd!Tu4$-$j+>6;l8IGlaxEb3eWB`(D+5* zAN%H6L0*oU>9@v(f{xC5UT$9g6{{q_U-SO25*P%Y=M9iz9w{y5P=AQ1 ziz%Z-?uNI6vd|%Hbo8&Sb)5EGf8aZK;1r>7<$!fL&4h9Wp<=un67ITlF##%n91pN9 zEW2ZJ%b$vWwR9Ad^A=N+U}Xhhe`UR=l%8V*Oy%;z9l{2Fj-i(bJ{rOS;sHOONnjzq z5DmE_n$Cto@rOhmS1CnZRhnXmi*fZl4h)(Sd#R4`>ziIDy1P}nQ7ny1ojcDHDnYzk zUcQP3zpqdmBKtL?6f74vc*0Oz^p_{(N>U*&8~(98X&L8u$nV%)Lryio~KA z?hxA}K7)`kVLL{tLSlD9i2JiR;ZG*w%4`hpQcCRy;QtIDuDp~@jbWGqFMd6z*@FZJ z?-Q?xo=Po75s}f<%a8G@FyFsNFr0p%<6j?{vae%iP3a>-WNuf5$)9cZMfN>P;j2kb zEE{u3Hf)%iKQdR++CrKnGOIg>J#QvoB9F>+=)RNlcN^33Q>3)-4A>6ZQ;I-JjyZcD?bS`bm7j`gjzzG94eHHF+){WYxQ>E@b z7>9H&z3TBRwWXI3HrCF%vI)gp)Tc*#<7i(25PY1!xr91tJ{tzi3r`?B?g27p!2LRy z+@#0jZ&vejjc@2zmvu0Wq!pH&bA7pYAAtHjF%@5s$cZ@`X-ATE2h@GmXKo?W4k25y zcFe^$*NKO_)XxS6g+WWr^__JTbmY|J_8EtGAW2l{C1-VK^|2D8lG1ag=VDlwN-FOo zkGXlG!pu=GhD#xU$cS|mzUZYpW}EQTpmT+(b(d*#a*%r6ph1#X!pp$Ow-D12Awki% zAETK_`}bbFvdDW?C{j$Pm}gqC+sxY`=#?RCWx&S6efJF6Vs}{+=IuK&^L{S> z@Tv_8P&c-$x}D9@XidiT#qmkcEoc371IGXR;t8fj5IB(QWlG;zCrO3Hp~@o z4?B~38=SkmQvtL)_QX>+djz7*_bCTw2KVW2#r`-S_F%=B{yH=5%CeBkC;bh@QabVG zI)SgrWOTH;JjkbPYIO!IWQsxxKZmwu(sOlkyvpe=>>yleIt!gS=lIm`SU*ztSSaYb zSnU3hR9TMcYx|s+v4MaEi#U`FcW;lge~+a`xH!2WzxMmhwBJ}pGl^n$XIZmO2GpEJ z(iH6$c_OJ6kBuLj3}knjB?k($XXe(XwX|%(Ir)QT_$us7pkRF{pWQfL(E5kXIq4m0 z1=_B_VrIKuwA_ukzQ)o~Wp|{bl9SeIi?@A5SR=RIw_;`?0tfV}adfV}@#9IlAKG`w z%f^>NR0A=`^q_Dbn63Ggfkd@Oq6LnG9?*8?OSr!P@;-ZqxI8m-+HHD$4pIcT6I)8R!3m z?8HOgZfdPV+Rber>hi-zn_L1*o2(R<1~VZbplvY<9IGsR^kuAR+?8_>sMum^<&!#_ zh8ld>PBry3>S5PQt2gVNZf~~UXYoy~+ub+mv78GJmt3tYr2)qer}427waM@92%uas6`)fbUoYhCv|4*?q)f zHnR2ERz2-CuYSq#IO8!0HZc%W)++kWosQDwLT!fE1<>vC&bfLx5`YyQt@l$e*v<7* zt`TsCk|WN4E@19rPX1XbMS<6kkivQU{vMp0tv+9m==`u^Yfi|uDqT}twj@ZlO+(Yb zuAm)g_S`}w*wQv{+)@;nYL zz1uKXB;XT_F}o8M3(`7tfYolEE#M!~KK%q!2KWzAy6MXSezT!6@l)z4Ok2dZ&vovK zN1Qvx3*Mg+CsD$5q%ZA_Td_V3aL&cgJy)07lGcoO(J>Dx zbJuht?+sGx^v%l2e^C)La8#HxtIrzL`_h^srt7`;L?RoDLZABLSls*a1#Rk*$N5|DMm5rXU;>8XB`rXuppb+}Z@0C?2EMnrh9SqfW zldk&Es=>?!1hga53d&Mb(#?tf%R>-O2+jfJ-Z?$9k)`MpsSHy1p`sk0zs zeM(U}O%&6odT#caxE;*K&-z<>yLxLO??T3qBC)wgaYW{7@kI^qO8N)q90Yut4_%&V zvNOmn&xXp;BW67hF9=m!pJyw9628W)%{6tsXIE3I7jVe2Fk`p+Hg!_4TeVw)D1jFv zo}JFg>^4lCv9l)nDfO`x1)TE{np1b|Sp@-zhO2&m z`Ym>EH=Nnqm}@8X>?ir+1wDft?i(@^JWl)qJZ*B4&$#1}Izq1DO{N<&o^Ma50phw0UNE?uDaMlr*Wn%9S2jvXtK>iE#G7r14E#q&k4LvrIbP%NVSxm!l6U2y2x|d5+_M8m0 z@Pl?ase)*DUaSX8@@jNy-2ADkDot-#9IB~Iu;?n6yC6(4S_FE2{yJ8(`d!I(!;`}2 zWVu9|Y z(4PHI&nRP4om0^hpDU+D_o${NHzlIa`NThP$=bO+xeIbWjwmD*oQx*NFA|=f#=DBz zrA)YZ!_K_FdBbY#p!`9lR&&zODmy4zMO^K&y|W!vJ-$ywy5v05ad6fcozPu;W$P$+ zhmR!eo*!pWAmnXuUMzhENanZWv5^Rntj1i)*G^^W^uFJy=n>%TsqcOt(I3w}V`bMj zA7gX(p&xj|dw5OUi{a9;#b_H;;Nf<d> zqEI+-HYHHyVCDPW+te=G&OskdVQ)%}ZBxQi4JGz>-wM?%oT9&bhoHB7 z?738${LGU(r5Uo`Ve6GHqT8A~O3@NBxvbLk8U{v{dL?c6cI5XC=qXJ$Qh%Rd54p1r zd2$UwoQH5W(wsTVof!>gj_S7P&y{c)xWrFa($3WHmRjqSy>HHH_+9yZNxs|BG{ugW zJG(T$v^FodG`q>MIO36M2v0W^w8n<;dauND!KKUr{e~ilyrG>w4vvy~h}ToqnG4estThn7kR6Y}-czQcN?p`tE6RA&ReI4r;q2*z?2H6_859;H;Qi ze3#@gOT42oCiRAM@7w4)114lH9)9R!Rz}8^!$PbLDHOXX>yW6Rtt?^7e(49!x+Sj< z=3ClNTXhCpSGB5Ru@hc2nId|Hox+=dV0v8gPfe@OBcCw4SVWUBSBud;X#Dto9IET( z9m?f!a8m8-2~WrEE62y;K5d!)YM! z+6uMcL{&vzD@ivC<8VLRHJu-u_PvOkcB$va`{acAI{IlZ`Hn(-NDh;B)WmNsPTPJD zh)$Ux(TS@@$@E$wXW?9#mJj40WiEl)b9k}h8)woGfz!ncd|rh~(<}k?@v)Z7j-P~K z)g`WJHFmahGgbP~-VjtL(C}FP6ob|Fh=Ypm|eLaH+F{J%%G;WXzw+;hM zatwgaoapr{oF$Ws8~ueFJHcV+wBf3y{xo*q-IgdE+~W~jdOeFTO% z5vRBEsZrWZ&p=f&40lg74Z&8mp~^80rJ>%`NS3a=2s<>@N7Xqm&f+~3(sUf=G!!h< z_$kxJ-y0OXQiv6Tt2z~zi9q$^nvY#{b}UmG)d{zL*3F(;nJ&&~ ze^pDsrU2hn7_vJ5ok}ZlmLp(P%#^l7H9o6b&aNzorMV@K%Mi(Bc*!+9Xg^JttiQ9! z*P?b4f2ptF87it!k{hqyQ^30W>ye@`LGRdic|tOIq~jC%R*`Z1s*exSYVe7^Jp9=8 zPt837CYf0YuUgS3xP*d=U70KzZ zXA+j_=`r)Z`$vu(pB5Z7xavRavA3ACI{B=Iggw>DEH1Lv*LG>v5x0d8M|5qdE{|v1 z3g{)Q&=4tNhqDMn%Ab}MhfK+CXS<+-DBhOJ`3}hAOA12+`|Fs%o-M-ML=<4#h07Z6`QAI9dFB*VJtd@9DF71@#wxpzNGJcK1!1B zT`d?Xx(6A2SZ48=VPD&;7!zm07g17TR5%aQip_=gj07H-@Ep2xcg$>++YN@gtxK-- zk-I@R#Gm~!Y2&!FpoPdNy*JkfuU!+4ForwzBaDuKO_a zgP6zJiF#0EP)W)3q(@0g&v^~}6gjwDZbj61T5dz+;EhbaOzZlRTR=V%eRn0$3G;hY zU2P`yQm}asC_0OzCkZ@aq(f(^GgiGUK$55%{2^}HNbhMxhJ z&LROz5L`cB-HV&kbA(LU0XX|6v;%My4BPy;onSGP4v7YF;doJ+qevHg>4%{1kP)!~ zpUSV6U8QF1eR02^oW>Ew;Zqxm`8(-O!6zH$Yzu?z>K7=WVZLvXgYTxE4*5vLAahur z_j}g8o&0t9xS}OLZ!)i9!2ldo1=`G<%=?OzZiER2c|&O$Ix=^mEePZUIQd;=d;zmv zl1R;a_6K1vTGz4Y2a8$r*EIT+lJcAbzh`WG24;_LSUw@QSmPBEkZin9$(7i3 zMBEx36-B@m!^6V^jU0W*qe~%4P=-ruDXe0skjH=#>3DLuUsfUL_%ggJ1uMS&2R(b( zfS6cY%XV_5&9;SMOEZO`wz+;#7Dr+GG zl-`_A{9Y59uev05X?pu<9sUof1*)Ms*#*|rmZvN}3-^kxqzS2@uWaZfTTw1=~}bDG4OQUUQ) z`%PxCbUk~aou&g^vw4AxC}Mf*r=6g^riNx6V^mKu0^IYWzka$|S3$_L@Pwb$hz(dfH*3)*)zNufzsi z0PFDqBBia*ylW$NIZtpDamJ?99SY3RL1@KhE5#|b{ujP9<9?%D!7B&3zgXY&t!yp& zF%Tr?e_-wYepD7Pwbj5Eya+`HF^K`e_fg-$K%fqwXZEbA>|$@^MkT_xB?-WheN&&g z;h2IbE(J|~3v#}S1Ox-uB;9kh7w2@#$vRlLw4>iJJw327w3VvH)6}1wEPPVb@C061 ztmV>Nh<=5f^g9St>KqhiWicVbSHO^+1$kvbrGry7r~#6*>xaDQjwzHc$h#GE+^Fvb zJwtl?zDKs~nt(^DSnb~Gj>RAO=)ecFa_fGO+Vs;&bbh_dn!%@a?DBx04jbgGd=q;= z@|o7~wh2MWEV3msLD|IZfMRL3=$9pAp~vG!h|Jp-^S&;&MF;88qRJE@0WTegERdD% zZXF~=LtZ^HmW5fdtJJCVqq&aunyapuT~Uy-nwE^(-2(}%0|x)4kaq2WH*s7F?D$VA zzYqfhENo+A=fW3HPcQShxVA2;p$I5CfQHP2C)e1`c{jokr;W9H`xK4Mxy`xV>WXL$ zKeFH%3$KEZ1>O5(93**)|4yzxIB(Xk%1tLo`35f1D-M z3}VwW(9b~-uT;lt%(J7Va^#XIUgG1oo4|2<_LC$yiuSAJqqCEBZ-bcZR(vLGXZ{&v zUC6?_kP4Xvu47Nw_SzUl+@kZ$Q4UVUaMb zd2bA^E3$*EQBLW@Wj0g-B+lpVcKU2!;^-l3V1&GpfK!Pap6e&?2VQDqnmU@9=s4-c>%iB6jAZB- zvy6U1kw~$iF@z9KUZ%*7ciJH**vO&tT;UauI#d_>8k(1X%K!?V5Ec(TtcqciEk) zE_=$Z$SH>Q*y{(+M7GKcY7xcopu!t_SZm7(SJ}1CbdB<~tKGDVKGbC}(}u_!JTmdt zmS6Ej6=VY9nk+JAU&AFpQeaiNy8WYK%BIihm*C4!8fu`MkHvWKCaI=kIP^JRRbQaf zM8L4ii&{pJ#;fh4u4SW^NUX!YCxXd?Zr|?QaTh=2t`SK&drL>ok!`)12MqA&Uq z$WIs^gi`G*Wz?>JE&n#)bzl}-NFiHMVd=JJ_3WDzv@9VtceUe+6b#Aq;`QR)EsT0I zV=FfY8qyn7phI(u$o(ecqL+Xm` z>e~~S#Sa*FEp%WrH=|X(c<vdFwvYqod>M_y0F|^ zQ#Hj++?B}p?J1+#mUn!E3bs6D45;TW-ixMR=}YMRq&L`Np&g?eV>{yzdTHFK?~7_e zT`684zpaeWF&;4UW3qk>qwYLeB|(^$Z*8s1{2>|o!d0lt(8t*OgE)7UIBg%UdZC1HvSM&z zH7KG}i@qK>lKJ=ievNaYEwA@VJERQlzyjYHHoOkVBAnc=H;B{J*F>uXT7eBHBXrNU zM#UxM^dmy}SQr>(Ro>FLBSJnusXpl!Ki(VHm_dYYBwWIe)(56f2NLdhL&5e5E1@T4 z>jux_$P~xAO{7b+sa}d|$ZoUMJ9@uO#~~h*i>Ce5oogg;_qxY~bl3^dT-oFhzs2wt z2Yon8Pep;TFMV(=qeg5fyrFj?o$rcr){u+NwY-`ilY|B|226^tJ( zS!$wPp<7|tXaXN7Axe2IR$QB+nxW-;ZF5rF>aUtHpSPYxY|LoUH%7u}@jTnCBh^kF z?kQ4rdicoqI`8J3-{)egxw$4tdK?@?2CneT^~s-mYt7+;j*FG#PE}=3q*xGTgmWaz zP{+aYV$3(lj^Rp!Af0nA@5>FsgPPcrjChwLYzv^eD+?h{Bm4-S#o1e0{bIMpfH9u*u7pJuAHgGOE{4G{!;ZOtYG-Q0 zV)&$WV~K?C6C&l&uKn`m!rm|K9oEZY>pQf6NZoHQ5dms$)c}ty|aQAnQxwzG%jF_tXaP!mWo10x9 zr3)>!Z9m&H`P}_N4;Xg6y_Ek4`xHM2|(aJd6;G|mytcC7cG|t*DE;#M_5RA2 zGDYusGT#fDmS(bHSYd+02~>BIPD{-?MmwCmi?;9Q3`D^IEV=#7bLHiBIO4%-CWFyx z%m=SHm(wv-iUyAYBGYLC>D|4hL1HSY>~AD&w}VgnA0A(B-0qT=4z~|iIxzl@973l)`uagVKySK ztvUD$pY`kyww#d{_%zq`ueOBf#Pd%GGcs`Lazs2(3PF)TI*^FgRy&q2Y=-{8$s2v$|{+UN?-R zs&Yq8%-Mk?m`U*q+gh~=bMDmqPczlhi8ofQ3fFmdH4bHi30&w+j?deK47zJWiN@gw z`;dP`1KsX~j03S!OS{R1W8xC~hCa(>uy&Bovl}&Xl#QF`GpO{#MukT0UByt( zm^5Ywnndw}VbSy?(ba>CzCzZp9*QKHEXRRE9>H_^D@Tz|!%XUd~O>?!gZLAW_Nti|rOrtJz zk*5uyoiqKjyz8P9(aAfxchEMbdP05snTxwQ7Xdb^Z`;x0Q0UFUxcUZf!I6vBdIl>% zqcfhucvf%G`Kp@&g1@2jQ#3nO49;*RXX}{DWoG)+uk50Gtm4W6;t(N@LF>Afe%?LD z$1K+WDbP52Ub$cDdvU2_xBXj!Oj>!HN5N-IpG=axN)~Kv7n2w4we3T!PihLmWoyPj z*T((wgOmOQb2YM+W+BzZ80T1Qsd`~zOilhWtx^s>+)`Zi$-NWi9k8bJTbCf0>753P zgof#f=7+6OSPCzy9`7mB7DVFZ((G*uhUjmC`4A*r>9fLo1q6U}TKSctlmqNU+kt4!K|NSY4I?-27;fII z_-1?LM<|wQ-&=H@!$MAs#-&czALkj6r2*#vJb4VV}&(bDASN@ZDH| z9(%=zlN*P`LB0H`(vvtrC$0(|aFC1dk7VzYQ!Vd{cO`=83D`}a6mo8B{&>Rvv;dFu z4z}td(buos_`D8%JaIDhv;9z;3TTO|L&^fw=d8vPx;wXE19sbk&aF`Aig(?SS7hfR za(y|3rm{Ru?#uPSK!A%`l@j(*@cz}-*sN83H0mJNc_%FdrC&)mk%Hkzzy??%;++_< z?D#C0BcUx-i%$sJFtq=Ml64>#GI`%!NrM^;cZMOc`Q z6p-?AQOs`Nyuo(H_k|Dd8g5P1R+DazDeMevc0Y2ig;2jr3Hm=Y3u7ug3Cw);$^>zAfl2>75=*;C$2aBh*@Rx@gj(d7)Gg zjy}dO(aJQapX@JtTJW?~p|3GZ=Pr1vCBGEnosH~1Z9G4nYTj*_p6(ccqfX3gJ-ruO z#m@#6cU&LA*l2}l*=RY?e{3%CL^VxPGlAHcFWpn#CYO9lAu}Hz0~lQTZ&%8Gv@*L& zBR8TXV(PpE5dZlRm&ftVDDzKBmk!F>8x;`(x>WH9QSYNxlx>;@{XLFPseovMxOxKX_c0;yE#2SuO(Jfx6zfL z;Xt@9teH0lt(SvnT#?sM`?DJKd4@hTr)9Jbi22-(eV4sDhey$y6{q zt?zr?;O^}%v|~`^xm>q;{K`s}XQLTMAv~J3A18&_rSoMn(VCEo9DJ0ex4X}OH5$i2 z;byC0V|bov$pr8HfPGun5a1p9haczz$*Zr2c*1bNFD_oe%in7ItHD?FW0CR)8M3^g z44A8uLy)KP3Qqiu$yXum@7fiv<^pKFnXan21?68^MyF4XEYH7$;FxbfHj>oGj z&T*Yw^LF`i{zR`Nv)>Roh>ScpN(%|T)DBC_p)j#QiOsD8QLd5gB*Q@+!Vizqw;5Mz zA;v4I%d>9hv%e&I{l7(|1Sa)eCEjPP^8! z_g9w*WX@}ySXY;V#3zCTm|UvX8c65_6TiM*?zcwS5ajJU};QtQkAzDn7DMLGD;<`i(JoW1PoS7I$1TG)nrp@8DaXNZ?NxM zk{vFvF{dN1I8=|&x3}bsg}N9Oq% z;U8M@_^30y>M>!Cj}QxU_Yq>9K`oSdNr`idbPX=CVnd@ksY+pdFTXn(8Y01%jq(N7E+;+jD7M6Wc z>%kcoisNnt1S$9W0$ngX@>W~fRyNim&YLN}Fra@mcn{$E7O>wV{^Meg`nM>B&uAq% zJw$$xhk!~Cy7qSImk&p3$F*ds8E23?tQZOAN0JBe2b4UeWA=Jd*y925_Rzc8bWMU+pjLwR5^7Yokg=|>-|>u1l>*zH^3J5WI<_suVs zc6be?jP){KJ|FL>x)Icxe{sJBW?hmnm6?~0$8tZ;a&OwEu&avft=>e#;pcYg`D zv>KjQ3>hjha@fIcOk?%htbyI^)B+?=6}Tdoqa$#57%Z zV%;tVrA;~mF45@Iq77`uifQGw-7P<=I%)mahQVqkeq=i*9z!R%I!7R3kE;^gZK|^K zXm1P=3Ix9Y0JBJo^=QmM%+X&H=(W^ zMfT;&QxI}*!r|zW5jNC23-U3i_(A=*jymX1zr(<4gK|l_p^%k!u^cfQs<6_agbi@R znGpmn_VozxgL1+b{FJrqFh@7FLF1loHv<>l=CZR1->o;>22_vv`D>S4!o2;mE1sfiI0K*M_-s)>@(nLT1*L$Pm90taX9ERLT?p^KAY>`QyUfprs!TCxV>VNxt zk|U1z5Lt5+VXR(f-n3liK9 zNq5BvWh63iakwyo3Isk=>6g68P|8=fjI^e<9bJt}DO6Wcj|g3&UIA@1LOLMs#%qV8 zO--l!wM!MU#;y6Ao1xKn#(KkQki=A>o9(a`cX+hlE&E1Cb5rUUFAj0GgrpSEjBh~} z!Li5*$7pz1kzv%NaO=MBJsh<7N8YFZE0an=&k0s8H!$bL%hKwu*kPPGYDoC%w~m7aL#n-SovfPoRy4l2x=cw9=B8)6ujPw=3i zA)5yOsx5LO=i1p60)y?LP&JT$d!L(EB*N&xv>~wQH-n>+kt%gjPCcatEVyHEYD1w^ zGwYH|BP3_GrkdMs?5SX*u|cel4%*F`E4fBgGXM=R)NC4Q5uDA4`$tBD-+;LMJ?=umsUjx>ov1=kf<)mNk1aYdp; zeX+`u2VM>Q;hN=>xd($K=B;QCHmmOSM1sR$d8IVjpCpMyO(w`*BJ9;BrWS2_u-*I% z?d;K7ZE>Ej&5mn5c^K9|MBWr>Whd4UtDyMvtpl@aB5lV`dK27{^!g%{K%f!l=9Sex z5Wu@2z$BSn(X2`5J_y#638YZhQ;Qk(XXClTWBm58GEns53P^p8o2fgEn!!gx^8>Fmg#s@>-kz#1RTvw8IW9Pg)Y3liJ@A2x*{`_&Q+$CC=Itr!7`nKIMECh0Z=sI_SgJ_?77Ld>uWg1C{ss?BPdYq!W6XJ@8Az)w!>wW;a()VY~yLy!AA_SNWr78FtSiOsI09j-$zwVrb33{X;)$H^D!8Kl*kM7`|LtgrEy>P2Gl&iYpgj*57Z1g~u+!ss6YHSS5@ zl}Ag9=F%V~0l6(=4dqK!*C2=>K{XEq*yaFzUA@7WjKB!{8;S_|AP6n>ctMm$!Y?g$ z6oD`_p;>E!osOo?r}6;*f8!blIZvS7qKhG1)-s$)UWQ>3`oT1dUHz{vEh)rhY(YPc zXVQ|TW&&zC4CydKCC~YA|6@OOb#}~j(Y`ldeWc8DfU$Yn`EU(e591)iaJUxz&hS^( zcJtai9@fnrMe$^YPUZ%B0}}we6Vq@7Wph}_V+S`#w`w5^RM@l@ry&}h5+$aA(VYPZ z6BBgn8hq<`hC1iES9PPK-$@bhD&iV2Dfq?IfE>Y~{K0ACsQHcg;9O#iw~#K|V0&lc zSFpB!~Ing*-|`mP_DY-IGR9-3%hXI+sYHaP#f{dQ)nJ_F>OLTUreGE0}&$l($e zqe-*8YA!2jeot(HcW3taM~Y=U>Zhh+u^WLeE>$!`G^50Mj0Z0K`=>(P-W1Gcj3 z>Q-OAIl@aCSu_-2Ce*?@m9+{cQsqIKk|FmlKPgv|dJ(WcUA;E7gKP z%=(Y5oX2-)7=)Ui-_CMgr7GX>x7_|tFc$Q_0h@*gewi<+xq@!(^I%b?xtLDu(f9_Y z+-LQwF0KcRWb~s#3l>NjxYbniT*U?sU@v{ICj&eDuhBDyrdMb|As!0T2H%F?;QC2? zX(7LcLdl}$YV7Wws1q{VymY7SUwl#V#>Z~kTRXS$DDnKGw@2FK4R?uu6LRc3j}=9m z((8-8h!RNNCjIC7%n|%d1U^`m^5MkaJ->S^R?{)Y%0++ct8%$GEn26{Y7Ev#)r9b$ z7&+OIyI{2G6*(n|lJL{i2kR<1DBau1%}1 z^PJG+@#rQoyNt{(mH4347XuZoN0I3-O8>I5mGV;Z^CzZ5sjT;ORjm6|%nN0hZ3atB zR4xux(gIGO_VKUy}aSn;<(MZb~~JS3?p{Jo>GH!HWdN8?zG z|2YZ{#~{iZCsRx+H?;f;>I=Mn>9LEo_981v0|f)bl#=vG`i=GZ4oazSm=Fw}`&! z4XK~?Ex)xR_j~v%W_z$G{H}Qfp5K;&2I$Hpt|zyZBs=o^ULF<|{}4=YABqgMMYs35 zd6JePWboa@MBm@Q!bEvLA(X{c)#sRt8e`QXc#G1s zXWrYh=g|0RxjxzkH|BSi`m?MErSTKw5Re*|oResHOP4e+j zzm8VmiUj5~wNnan9_Wlgr=FU@;oq|&fRnP-xx zwaNM9*x@93hH@~dD5&UOe~_(>&r8(p+UGyZu?<+NrvB+EH)1U3cTD0e%Sr8Rl)vNI zI!zO@#6QP9J1lVEuF&j*qB!FXye~j`x;=H~LwW`+rSH;|Zsh zgnIO|csR8}>eso8p1U4eyo|-qY`&H2Qz`6|$(NHU?sGbPCP%25H1Lcch$}l38`e$K zBXr!RVk%T??IDPOrmLG~Kc6#5rK#@6+TSjJ-=xWGqEw^;pBPtIsQK3~B^opqGVxnQ zriALV+JNO8f+Al){XVVVSK1jZ*swZq4m-N@26(Ff#QuKu&6CV82ZGh4477PG9m_Hn zpQZHm35ev9Bp5Fgms_g&z^(SG-v8q4-!E`7&>1@!sH+*9$8P!Lvo0I6y{|px8e$e{ ze|5SVaJdq24LITfj_WzCdq#F023R{=&67{17pkg09v`wg-zk26VTKSODK#BbDpZq+R+`L; z8Y0x;ixAqa-7yrK&qD{g{-L2==@)KYGiUc2Q@1i~FYqiF$ zMscp&zBKfY^mDSEO=o3ouu<*Cmy;o=jp=RCI;PIw?+Th4ioJsDbz&Q@&e$sK&|4yh z5D*$Pwb*oVwIbOmz*7y3mRr!vRxL>SHp4}>vB_)*y&-C$$CLxYVRjZioBdVbK zlk<`0o=m3++$D2scN%3RiEgZM+1cd0PU-_`7(5YCJU`>N{)Q*&0kW>s3?#^Id}y8s zzWs^|Pi!I$yA3mH6&Rl$C@on1RLvyB>z5p*`+4o1Ec~8{N@vv>; z=H0>sgae^`|0DApHQr#kTJVskSl1gpiRL#Gi9k4dTh&$YvXVG=WFA&BnTv%xYhq!Y z3s}_R>1;u@9^qYY@+4_T#cJ_eSoJLyb9;Oq2(0Xv=+m1@M^GumwrTdS?oN5XWdFeS zw+g}Nq!!+*qv0#F{-dAB^zRsVcMSo<5DfT@+slkO0%;bBONQn2KxtFL%P~Tn9I``2 z8z0I=iRrX^$LSeilT5O0YB(AXy{ieHBf}krotSW}Z5DVyV)nj--IFY=4p~G)MerT< z=#2w|yIqOVlM=W!1)X@4=e%%eH@rMEXx~ASlxNS>ag}3C$1s_IgxpYpQr_ffD;vk3@BSE?ZLx+?T_*&~ zhbapJktMs0IQt=w3KxNg>XsH~lTLXz27dO!vz&0w;#8_!Q7Y6{~;#Oj6nHV39Miu2A_T7svCrXEOO@&CC|%Rd=P8nE^Ov*iD@ zlY0d-6ny`a;XTeETvpX??Kr?~5pnoh z@P;2F4Ls&xfu^1Qmo0&q21&nggdW^8iGrdcV5Q<3$Y*Z@H|@ljOn;X0+?^ZhNHH)g987E@YdSTZDr49eh8-I6;rsmyz6b1q27|p>-|Y= zRDc_wXwuW+WO$|WX&qH7p)B(AR=pCAMwLiE6pYlJWWGP}!|`=XZNGJhH(o#j4%fjd zSk_+zNL)50;sz9avmPzFQ?Dc+GQ4?1`YvAplR1>c_yXWeq%0-anIlAjYOT7X^R$|U zraFf|F_SfS+Nw|gdi5)!xL7*Q&G)F`qS<5<0BlxxC8!`eVU&6t5I{JRuPNlG!s z&m=X9K=@o}2;&Ctw`=rt@)X|Lj3o=hwr1Fc00r;BvThuVQilsKxyYsKV6_YGYp})R z-6yV0%_G4rA1j3*s9Bxc3w?*CsxpHC5k}y<_3>`##PZ*6bT(nHJ6m~7O+Y*wSG|jK zl15k+7sb+|CCfxw6)vlNX?YbJCuyDeT*XLDN#659aQu|FZ930`Z;Kyqcm%b*>Xhg+ zo;pX_B=}Pl>5wJf7q$DQudySP6MDVVAJdsJG0?zc#Q;Nuf$x8^Ewm_sx`Dwm9n^Y= z7U;?ki^e!pGI21GZo-M5luM14;pb3ymrU@G$G| zFn$7OodWmX@XaNc_;+m&U3TNVe*!AvIY6~w&h(`vQ{W8*-{iy6$LdZ)b4*c3*eL8I zwh?&auxR=Mu5VMQZ8&&1M?Ewlsk{HD(*4ZXSS2WihjiR&HiS6F<3r`572hWd4r{T> zUiOWrL<2C5w;*W0a0~REz?H6g(9o;|Z)|!v1#kRr>qw!Hh9+ygq11epJgSkOmLy11`JCr1qafp7^Dc!Yvr%5XSDivRfnta`|ZF6{#WlM zC5g*dLs$M6clK_piWM<9a zmXLt?Jv$p5exr%rbRqG-X8&?><>`(3e0ergdlsPfZ13^c7txK$ zk%c`2Y>?U|X=6{r1#7X4n=!(#1;15ewJws_sg7#6x!$INaV2ss_B@$`V#&zc1(7i| z1*hx;uctUA->l5Hiq9M zjWZthSkK8$u8Z_BR_GrGH^M7qA%?&&3#W>)LZ-pQ8Lk=IzXL?3UQBe;d9=494i}vP z?Suw%PFrmna0_@MmEwN0=%=WSUx5u5*0%xrhsk0y6LpiC2R6Z2k-ji7Wo*fm+aA(l zQ4Gh=;GkEvvUB*|H>nTsOZ`Szk$743R)?Bf7a#wFd@VjHqmr(I%!B9g;^t;4Ry~_B zSNU&cpw@b1gv>-aCKQC`yrkwS@~LmE_wO%%1Y3f}9(tLC!I-a>CY+HQHtU-RqGLz@o=$J|#|tmQ^5eme`9Zl@?bh>0&c@t6))e}})Z zZ{Cg_xhbFK^T~<)?hb6qK|lAo(i_eP(P8+M_vFIIyc~3)+3vlLPr>{teRCPSMLe%6 z*~?TwJ@q6moNAmONu63j1O&jZk-aP$Qe6o#5KANG%noN6B`uN>vJMdne3Zzl6vIH; zlt3FWDH&c3FMak+3McUA-+RE;ftfOp-tf#gV)cRq+jBKyV_=-FIMRO6MqNvKRlj7L z4J*sW#T-6p6LUIE$#_-*wIkC`5cZ}n5Od{tj<)x_C|=|5w~44G_FOpPqIUf@t8Ix0 zqc3tnd(PZEtKCe);bdR9ikSPtq5+9#Sn{6tXxLDW-424*utO4o3+*FgqpOKIuUwJ*gdnqD`M#VikLk68r8 zM#baVm+U?8;um621&uY#)TgB=>c*z;MMgIp7!n^j9^#}?j0*|CXu}imH%@s=MRJr= zl&lueX8G>6gXzYx7(}~tOXJ5U3gG!q^=II1K)eY6&^uacYrWDlQE?ALI=dtqgQKIG3+q-6hWo zT;E=FMj#j0Fo>d!z}df){dyz59oqo{H^Watz2R}1smB%XaYqj+2gJoDw;~h(D6EnBxE)N+JWDj2&IF z{!@v)XF`3qIYUIEX1Ud)heM*~g9b)9zK_QGID5us54(Bq3^iQeQEygqwic~{nYH#f zKq#2<`(*FNEfH#I>j+UJzgcEX=F)qOcUlm;MDye;(3|T}{xGrCI)(8K zs$8nNKu>6tbloX3<1e;iT{@XC_5?nx@LF^aXM34sn}ZfG*~x%t+JyHD;h`Gc%KbAB zvfSLvHF(upXEA!dMOei*0yi?cXrNxw=~6wOGZ0nqT}Ct1zeF1z9@(~L0`*N;nlcYV z%dPX1-EbDWras1*Tx5vqzO{W7n6;Ahiu}{?e&WtDG(EU4`s8-|e&o)o^H$fk*M%)U z?mcC?(;iXbFUb2}<+F}A`TIEO$2b$;g|RMP&xgEq6r3%z4p$rJ#PXlr=Zu$c_$&}( z`Ho(-ST<5YFdknj&GNGE&w?HJp@s$_DH&L6S#Sd`(flWhY%lA{$fm!o%!pk4kOoUy z`oQN4>%g_Y-h&^Lo<(#2Qq-LB>%nt-Gy?Pvdpp@WO(x7^*5sEPs#e+&CX1PC9fx8i zb_zi?qoVlTsXzju{9VPzU-djmK~N}V?+d}qJDce*;)bV*J*IJg>o^|2@+R1Dq>h{GT64&_j(4)rdKne-*zkEi1nIqLU@ zWx1KOkv-P<9!5rQ$`_v#cnGfHf^wBreOj0o?s`={?XjTm()O|58VacHh2nz01{ zn#^^A)m;sT5udq6sKI_BHEg=5{l2^14y}G8sfh05VKf=1Y3S9 z!<=<9Y6XM6f)`DUZ~Sb9zh^^nq*-+poc^a8IkWC~Hys4#ia$r9vdkKZ=!EYuhCSW> z?xvCTi1ywyT@~f)D)>tA5d}5K7cdoo4#{z~?F9uR#&+)|KUU0u=sM3B*Kh2;#^Rre zC{I#;9Prkf#r(!Nw=}bo!E^Z-%NgiXTH)fyqMd3ucWW(9`o1lLk<6lZ9w{vAm(sE8 zOZ@8*w;|=Ve&;{4+8>=sE+Xy@-i++=K6=6vBVSiiNMvnhw$~l58{)u%jkR)Z=KTOx z?$A)ME4B!d_joxq9{yHMSyxw(Ia=%51Y!c=212j?5x`4@w>U{ViWj}ymfXMsM<#HB zKQHJUgwLGF)3!*?=7fqg;-5aqcaa*57t2>-XB7B-nrRIiKVgl7N>vcZXWd|q%d^jM z-BTEsro15H7 z*^76$ve$|vx=$qkzfx1bZ>y9TN_A0hX}#)pHhKK5&GqMfOQBCLeWG|@ng`S* zkeTo6s9vCrBeIJpv~7@Ik3@Ft2ZO77bE=DQdjB{`!@cLaM`4?42W~B2^sHYcWjzV_ zOaYNp##yZ;=c|`1zW*s~=An1&1r zQ_|u&kIg9!PP|bg{RcbK+A!MC0ZQky{srn5>gC2Z{+Z@#yv0iZ#%t#J_PKSt!CaD> zuX?+kDyez_URmDfQho#%sJZDAsWOqh078kOB&I?b9?syRY0zUCzog_C_j`$WXaaFO z8Mz9QZ*WB#TQwD|H+N2rLEH^Lzy6#{XiKl0M6WEMoMavWd_%TliHkZqb~PRzF>4}U zq{A_>xLXd-)+U;;Gs|MHJ2Y=BrG3ShJgW)+7bn!cJSnGcYkkK734+VPB}5Xcv*;qn?egotuW*wif%5?xu((GR0ngV9RtaZx`tC zBD_T=-~VK%t@c-ptR7Q0cOF|Txf`PXh>z`pI+EqoLb{JYStjPXnT}iONlMptpZCf7 zgCoB!|1B9_AM|jEvL3uSGBcl?L^98m{2-MHS2LJ}c1ZMe zRt1M5I&cq>a47;mqZOBOWJ#qhd@EO+G-sZlq?Bx4D5VY2fh^}073Wnoz2rhuSg15h zKetes*49>42EZ36HlX#NT`XlgLo6siYe}JVXnv@@G1w8yYmJDr)nw!7qfp@@h%U&{ z)*Q}kRNR>C{u(Ip+Cj-JMzL<~^6!@F3qI5FXAU!05PIC9@9fjv#fcs%4}akezbTx{ zM3-p^#vkz7za)JF=B12wqdSaFx$cZP8XQhSTXGx^t89AiT<;rKOMh55_5i7IbdJtF>{hDB*y6A}?axoi`G>!=!obMdHvrJ_X5f?R)e3+ykJ--z4>ofO^ z)_fs_Zh`GCxOA$YEINn1kqVv_3SbH}Ixw%jt&yEC53(ViEkz!ajE63xSvK#G;0uGh zo6$`6{BklTktswtG%AhXis*pFcZ{jINA$?=kv!|jyMN#!c>@vJEVuRR=Y^~v4KB*g zJh-g3zI(2N6F=gUl7cjVoZztNX-r={k*kErvt>p^Uf!?C0#g4k70qex6NF!z(=j)n zNvw~CAE#>_zo(cF*WOP0rCHcZRb3WNUL?oXxu@(q-_gWvxM(|D+%aDj3D97ab!L*Z zBm5UZ#*2;q0CHuqs6%*sJpY4^Y)grRKz?>nN+Hu@;{Lk3IniF2E{B)IQYiy4k1ri! zysppwOm3L@8M7s|=Y6gvOE2m35ZLeh@(ZRMo=3!I*&{@$_?pHqSznDR{L$!=n+4^1 zNd4-4zv+I}zZj&Y=ipfre}r^3E$jCGh@{S%5`P6Q_c%oRoA0plEZp6Fzv*X|l8vFD ztY1+PQ}HkE*B)H@<4}1R9Xg=n^ZKC;h=FW-2Lw)RZ)>X;%J(h`%v#NEL-!MC2jI*4 z@z5U*KOoesqGMN5+5V*coQ!ill#kfzSJ`uwqo+qf=j#tllk!+2fLl9B#HRnTMxXa% zlZed`nX-s-m67k?ZCH}B{k)cVeTgH6>Ycxaqzp)q>Kd-h=6Wrgs$~{O_X^q5MRoR^ z)NbbV8+#8YQ=3{dyQLHz?ImQCp$zzvej_Rn_4WYZNFiETu(YiBWork}4>uO%WqID;%r1jjf0!`g{I{h~lBwwoPZ1}eJaY5V+dGc;sIZe^+iIC&dZ<8Fi zipHbFl+zBj+&t{JyUL%nP$kPf7U^vwy`MV&oE`*q$;#5jaSS~GCA%nV6Y6o2s6u58 z09b&LgAmkOcP_d739KFY*6&#A;&~eZ(3W`d;>DoJQuyz#Zlg!C{l;nXF|i|uFKM38 z(ngQvL`jg_D-0{KXwBb1YZ6~478{#2Cw2*hWLSW@m!%YA=&x4A^h26totOpWcFL%t z%-<^8lCUX|6%@&3aHeM@q?g%6vB@JW4gi>CM=Ztl-1+{OKO5^Ssf%cYRTHGWADz6- zff7&9Q;G2@woN3bWTF&27vjd-v!~o8i6c$y-e<~-@ZhbJm*2Yh0%UKXM7wbo$;T=>L+m3>7Puii8DZ= zr}KE#^4XR3X_|cPjlR{Gp_Lg?BO76Zjs-fMT59$qxEuM%F0Ze~VycDpX5*OC&?j>O z*Uy04`x5rpI_J`Bi|Z$d(CBT%FTiI-Pha?KUtd*HgXE<6Zl+|v81gTO31@^V9JaZ4(^sns`Tn-e+k%r#ZxI7@8wAKR;V;O zCR;QLkK;_O=6g!v`kl(BI>cWvY>8kWro6?*-|GH1sYsEYK_=19pd1}<{2K4;aU0-_ zFU)d>;Cq9lOrzW#Y~S*6!^aP8{dx6?g%K{szYrh(De}aoV<}-5cvQ4gj_d;U~o^5~fW{Bjp=P zY1B5Y3x1w3BaRRY?+n(oPKMpO+Ic_9uKMt?Lv0#!Pc`~i15EX|cm7$oN6QyjSQS&w z6Bv?qWUTNDsT)M=)UD2IU*z>6BPE3xZ2r=P{NS^V6!c|d8-YY;`}Fz`9?{$h1__!} zzj4~3cWbczj&<9V)U}#LHDlXx81Xk)-JcWja6Eker*OCR6r(O+Zw$}*^Xz&Zuj3>lIKZ;+?`DFG2*7n+W zvRhRP;G-J2>be@c>1ye^lK*D?b-cTl)=i$sg%=hQecy5?9NNtyCs4`6t-`@jy@=kQ z7K{#rggQ_5r<&>oVh2~0Pt0e4$_bm`t}Fhg(vO5Oq2Jl*1Kbmxw)Oq!b^VGdD@huj zVk^>g^r{|vYqwa+P5Ip z;h-~wnUCU3FVYOuu8p6|b_(0hWy@m{_LyY0C96HIe>QPxHXMWmsA(1t0;hcNh{)RzO2FqZ!B>O5zR+f+0h_H@G8%$%`oK8%oc zH<0)ucI5k^F)L8KDuqdV^aC?*5diPj9k%|d#@Mso3gD)6f7C@erklTIgYO5Gvbgn< zwJz@##dy+D=6vR6i=9m4WY3Tw=szXyQA6|JHwzHjfe+sAN*sOsbrSA17;Vf1sPT;d zSRM~r0bVFcdEG~{`7%yD6aezDG6>eyt$@2W;AbWqRa>@iqasFL>z@)-2_zCsPNxhe z;5S|w)aCLdCiE9N+NhU1miw3R8b(l99Oy*&12iU7>5&Dbze>|FvxVEk*sgWyoOkg? zT?gIEs|{SXt0`I|_E~U=oZIx(`+6D;8+BqZ5lm!~-IwhN*>|2GjwhxmQo>>~aXsQk z?F19PC+prUQeTd#z9&5(`xZ&p9qHCf@z{)+`GuU>ssH^8_l7rGkkNxEtjhF%_b1wl&6K>qVKq!F#X%e4x5xW zgjocyh~>BZpC2tnQ{&7_;KysPr+f-ma#OJ~km}>JobWqc&k`e;RcJb~YPdcJ?K8bf z^531ji;?>`U%t8f7$bk0humCG$-^#NO_XE&T%InC-MV%7vJOTUTQ5&a7PlSgj=Q^0 zl5s!L(*-aWRt6sOmHxI>jM0pe;Vd4EF>^0sY1Q)!`9iK$BhS0{f;Ayz$WS_+gYAdn znPy9#Su!~f{g%PGAN@npq8Mw+L&K!sZ`+>)6L|cq#ipXls`SdsOq_e{NERs9D^^?3 zy2AjvGAa3FS=xpbIztSFZ5hwXDGM^IPG1%BCKSJ|OVqaWl@w^%=)aW`RJSvtCbdNI z?gfvqWuVIdcIiXm+cfQJHrs#$q#Wn?g2r^ckt2q=FWNnD^O!(5uNko#t+U}zPGP^A66H{WK%B9=flF*V==dQ~pzyF=m`e~L0%eI6l zi-wY17K7Q|^xqllssj4=q=z)koZmf@i%=NH>#Mpg|9(QwYO`W!VTF25G3YH$U>%G1 z*1KeumkNqB;;M`>?ca;$LPH&g}O*v>7azWV&a zNr>9lOvuGRPU6GW+KK9F{Z5LlKsclJe3E&tcCNNcN>b9$G~F)ilP1GpRI!gy^-8ap zkiDyc{=}Zo1JR?0dm7UsPXwJjX{I^>cWUERsOiRmOPyLqkPHUX;&2BgUNc%lV z=<1+jGXRj=+6m=Ll^>Uq=35Nsb#;U5ILqtUN9)Q)TdK-kb;-(CbOBn^=l1QopY+c- z;#f{V>+MAfyjrxSN2QSq=Slf!t!oXSrIL09ytMhEl^ZnK8XN;ZZ)2Wk%GzbjHPph* z$2!t@?GDC#igQ>qPy!CRs)dsA;mk}s-Dm!XoX<8U7VgAxVI|i+Ey#O!Y+JinQ}fM) zD1vH`~v*PX)8?18j;& z076Uu5I(MM9b8nhcbk@-?%<=EZWYw0a=AU@v} zxE^{UKiX*@@)f)dtsUUu50~Kip61QktBAn7&Kq&?uC`V2pOZ4s{xFKtC(L{9)&4k^ z0QJ|y-wRS7s-QFW_^Yim#W1}sQdEORsTg#e=>`WlBJ@h z!BRo*mC}DjU!*GlAN+IIcWj4>f)SXaXOBw;r-?-=mKycq9|zEZxdm{;VpddQR=G z<;fNHrS@eUpn&HC&nmOu^7TzbA~pI>YMBJ9`XzmIPhReiJSdXV0pz>SjKZa4qZyq(=ZV}{o z^WOPcj$wq=(HcdBTak~4F{JM7Q@s%L#{GQ;yd+ELTm^uo`+Y8#C74$#t! zHl?EAqdQI;gD;2a4JL({aZ*V6q8zv_)lv}DgLf;{M-G?o<%%Xw{J^+IVMJ?063pXn zcdbmSEcI%uhjkfAT9bc>5yRg(et;vaua7T)a6l3P0mfxV52%=veg8qX?k3bmCOR^t z8;bXFxrdJ(CE5Q=y(IL5r?|g=WUIe~K&j{%fcwcThPS>nLQqBnl2wt0MR}wy5L>m zcuW-eZ1^k8Y%(ZL$u3*vk8!*_4}H$*W3oQ7f356wvhAlSn}zNjtfS@)>_V9}%NGcM zwBgjg-QpUY`utS;u6gki9P=|&_TaiOgOid9@6~mi437Yw$=IM_%+BWMH{6VmCB-@6 z_@S$F@>}UohCb)jm{NHJlwA=-GpA;%dMCAQzf-N{E=T#auQ-&?ixCHlY*lnEc8eEU3X_&Ii30%n=z!fuIgDv^~YYMYSeq77$2 z#V63{F3A5xWI%ASr!`lnRFO>`N4^aym9HF!QN@zV5(*^_B?ND+t4;?+JpMB8koKnb z>T!4D-y4*Wvv| z&$A@CVc*6jyfngBCP2Qv;!{q}*Z!-t+gnqD>P^!1>a!rnjV5D&>a_SdaB9xZP5_DlrlV4S23wN2= zoQKvTw3k`Jp~o5+e%GC;2Mzx4A)Z^E}o^sWHD`b38%>z^1QX4y+=BUu`}(`B#-Xd)`nMO&Nt z)+g;d3%hNrOKdE(7r6(}y)8D33RdFaL-N4mmYeuM7Pm_&+@WGmLm@W^xNW=P%?4~t zAHYN|#37sTyDr_@Usw&;T?9h~$tX_xa7n95n>xM#CW4sKSwWZ7Xf9=7rv!46M2E;7jpBrAy zkLWdjPDIQ0*ZE6Ee);=yjGuQ<7I%Pr_)FK98shigQ6@+kD{`6LiR878rQn(1kzjTJ zmbMT}05tbTCu6s^y62fn3Y~#i2X+aI%ypie%a7)~U1lt(laEweg6U11KXLbsZPr zKCMs6RoZAJ9R|t9(+uRYi1cbWL}i7f$@8*kN!Hd zf&wBR(cFFY@8Tl?%%dEu({EnKsMxyigUVLR^^?Z~`r`+Aiyz|ja;`H-8_V=6UT zqNu0G9F*$6?$w37Gu>7r#2gXdvYx>#s4gL}??DDIRGoR&M%}RJ1seR&juKkifly}exO z4jmfStmDqS2R5Dtlz5OF-@$2r$iw*O;zWaoj(;AD5KUSH(zR~CB%3D5D&hkeS|ree z8dF+))ZfZgscXJ3DlHpL8y#%{m$2&A)M{g--N~EY6MiXCUwC z+&Pg1n#Fh>%3>)qy4FY>kXo)+v-o6l!5`DQCI`9+olft*Hgi3CVX)SK^rhM#1^B*Gw`7N^E`qA^9p-vqBW3;8 z8n&`Q)ZWR-EE>s!f@wl$WtXb_j8%=b4C34*g1Ka^42DBUT7iO2b^~>%;MHk{i3t#w zunPanvxgkY%0d~tMG5v>{)#nG#F5gJ;h43aw zaE>0jadZ{m+7s%l!0q4%PNtm!Xo3)ibZyu{4a z4N?Af9Wjgp2FMiGmru!eEP!)`hE#A1K1BA((uRng=8_~hf)i*J;KI||u>C`P<> z-K(p9|F5hB!gMDS^dFeDuSrP(w~BrcgNM9R{XQA}(J%JTMx(QhUTgLTK9Q0JQ50&|ON>1`lTmkU%P@mhH6$hbMxT%z0 zE^V(hlkVFIuGDo3(L7!CPKj{4QWXW21;lULX3eA>At6@@FE+GXdnqL>2zU5eADvS^ zQ{t~1qkr+|B$ousbQ$3<@6;cFh|p#IAm6|~qOMh04nY6b0x+t#i>5s=#m+Sq-7o@1 z@VI^f3qw(kd0mHQt!8agho)mHT73nv-L!~-xLuz*ZX6Q$FP`0+=veH>Y1{wTj0u_v ze;6NYTkjAkppPN|s_e8cHEL#D8csRyyv}|AkgSviDwo=+tj81XbRf`5i-H!3$o6r) z9xk<_IyblrO;C7W$eH$#s7gRRX*MTPF?XRm)8mWdOou~jnfUwbRGB3rB7>PF`&Ikv zgccikCr}7;r-KhNe~nIjB>;w=04LuFUJ71E^DJzuf^wGQwPw=HOGS@!i@HlEN*)v4KrIh|3? zU6cmGHOth#qJg6wy1>2@?{z|a*q&F9A^qmIhY+jE5=jx!`lK z#xovxA<(U6SW!@p|HH7lSlarknv-?GzCh;WxL^K5w)D6k^SlSdI5J@Z&-gzv ze|zNe=3ToC*Y>@W;5>P{iU%BY4<_E$v`F8<3Een)qkqtqOo~G%|NMec>>j_2;M_2) zM$nOp5LKZ;%0q&bVMdDB&uLmSjppjlh{GQFgp`HkC(-{OPhY{-R-i2#Ah^4`I}~>) zTHL)ri#rte;BEzq6qjPfin~j3D^lExy9Sb%bMC$G59G_Y*WOEJX06FAzVrJu@vBzF z1P1r5{`xubYx?#}FZazym+JXW?LgX|_my(*x2O#vUTdoXJKKlHs#xR)Qjbv^!oSp)McDQh6Z(fW1m zD(bd@H+*I}1i1w%KX{Xc{yw@A8v5>&^l%}3w4_2t*z|0IuDIYTKjjpY&_ZYD&W8B+ zson3fWL8;Zi&c>V^El-V&W^h%iU4*v8T75&N! zka9y+9S%_3|Kn|53HOA+Q`x_wIQ9ecu?0Vcb(*^cPXlU4i12MCej3%{ii9f&f%CNnXEA*+#YB%E-45i3H$L-?%~XB_LLJqt@^4b zCMC6(vQ6n%Kk=7u7?+K643t0IDGAzr09Kp;b9L zEwj=VVx@)w$jJ9eZ%XM)fqDu4(%1aTw&8P5g5Ni{k}ogkpC?tL(#t9;Qqw7(zo4Mt zkdojl{XM!;7SR4sN3Zynusy>eb6_h9 z_gXKM!tOcPEud*0bPp)3>wyqEOCO+(NCjV0Nx}m}pJO)aG%Zb)C~t3mTuRvmlFJ|V zzoYhw@-N-}AfAQA+<5FH*-5#z^`p_ApR`&gPAeiO2P+EkC&B~(5Y@+`39O(|!$Bn^ z+t!uC!G-HqjEeEY=2Enk%h+|KK{rJg17g$Bsn*nqH7}w&k!;osm&O^lXh--<&nbdgugn4a?gAK?X zhIQWH^I9(N%?r?DAL0{y5Tb6hT6)Z={?t90X_7-`j9}I&q?meEh3U{P`NL zw%Ct*uGq)t>#9*xBdCB}WE;10GeukyOa)oDd;w#5+~I~e%Q*TtelAqmB?NjjTw$Oz z9Ndl|o2)vWz>}N4=XWp`^_c`{2>qs1B4Mf6Jm^c>O( zy$lvDC^fRe_)2%J`6syX-ZlQ@y3$tweC}308SU^R?~=;(-0e&B9}tT2LFB* zjYolw$GFTrmt-*lW1n=$Vjs&u;ZmS}R_Q1zsVX4h0a3QjDS?O^1tO811DN z_G7HMEQUOD1il`))F*g1&cG4fwv7Y_{AiEEHdGk4FC?2$$r7J-Z6Tb$fkAx{-5V5t z*Q6GJ1XxU4#3!Kq7l_VCNFGgJQUMxX23L2#$febUDCACp?2Gw;oeZMN&s zC2AL zy|MU1d^95{a9swD$NRGnZkBq5-O8$(fOC`cu2XVP6Ea}7SsYDcQ4A}Cza9>2{ca>h z@p!Scq~BMxGcOqI{H_3dPpR5MhGCl`VKmn|RE8VCyiYhW$`_*Y#At+|$he0ZccNP+BhpU(uK7uH@GP^UGL5thgh zhQ@{BX_DrI#wlnCk|r^wec$oSQFASxQK}a0=3egHurW9gbM@Kw zS;q)Rzh-Mxr2`2p!klVqn9cUwV~w(Se~w(nv0 z_i6xddj<)R=sGF3XB-j#qv@ldF*sKGyKy9@A`8YDW1za?cLV#ktomDV3c`8Hyt@EQ zM`;4GITlJqEh5`+&nzsY4}vjt;Z=#(_qcXyaZUQ7Q-Rp!eZ{H#;||j|EB8u`*Mv(sqF8NcQ#MS6I%$3;oZa~AI?G;A7SJ9k~}vC#;GiWPr(7;UaN ztQjXTrFzEp2I@SpS@tjl>`DmY#;B>|yrZ&CPA~^n1_pz)yv0G(G zfAZTZ9B>yIfd?KCd0|d#uZ^TD$F?M@GiNxOxHjXI6V#b_;d(!ng@h1SY@^t}UXd0F zbGI<7GWHHI<9ItA;3PWmZ``U8lJBXUsA)B65Zi$dWRxU!!IXHGmT@1z>T=|i?DU3k zE*G?0YHb|nTxhg4pLm|SFO#(W$>>U-gT8MHLgh&bd! zv~ozuvSgULg0tKoD28n;RojJnk|I5>GwKO1OSOEiXzeg!>m>+(GbopjV|cxbn|J?; z<>K(R*%!0g*M;g3llEUWB}HrmzL<2otSY#ecyRD65ez@B z0af-+r$vpM0Zeixcm}(nyrl}j8$56I400fggm8?hNC#TaD#!-=Ydu9C9fH( z@muQ67}kdUxATU+J@*)>yIt9yO9rAq{cRupG-U^3>25i|1xdls#B}}P3cOj;f4n*5 zc?o;{p8VO9heFtogPm(nux_)$UhVzz3B?(jne+4W6a@0w{F|5AFS-Xt)}$EAhg4fK z1`@>z0h};e5Q^&v(5|VNL#vwhyrqMsq-yM>s$ybX%(jNmdw>IT6(J=?sYtstVSXfw zC2m)E`m0k`X*h>U^q`e!tV_9rXefvLpMw{4VdN!ct>}(8?(y5V zmT95dzcFj@mwm+B>^sJRoIQAz%bN)pBqHMduO%3Qn zg&vjYO<3=-cDd1cIfw1fita^6T@H>tU;u=3qc-?-l3Z$Lc_StAi`N^36L)7oDzNL5JoI0s>f1db@7frDNm@v zIAJy&lcS}jX=B%#NBtFEk1IGjIR?6#<|XCt!XHY66TfmyAst&}SszagtD-J~lHuZEbaEhiwfwCicvKrgu+mi;>6VBm8c|`W|Q>)`*HaI!O;SB3pOW zZ7w0>5nhIQ!KT|Z%}n8c8T%>*VJ)l1je6{n_T7>Hz~zXrpWMy%um&$NG$_P_Wa&uCAt1+T>ZD>;uey*u71x` zGCLH#G$6Wi^xE-7qwapf@wtPeb#2~A5vXbJiE46589;n(7%vT9NC*$oPGRs3C$Oi$ zbPtd{t=YVoe+zEgGVn9g%V=))*o?K6gl!CIE3?7^KUV6VZars_lAv(xhJ(4lGPOvf z7mkqzeE&#|G|FL-m(F@4or*y-nq`)}u1!lOJd_n-F7 z5eztsr1;-H#zBjgADPD&!&ii!_~0oh4{(!S?=58?YLB529YKk=XBp)wuaiF>@KX9T zHteO|hhZsXxODI!Do*Hh7GYifyXu^%#DVV&el3bTu=MBWiFuqhHu+}VzNYtGjdECF zNC~Jw?`2>ONDZ9>z+&C+o3)Yf#{vKHF+9kO$ogZ(lJV(J%S-WqPe2@bIXNp;f=c0} zUmU-G`SK`54O$7uAS3WmRD4o4`im5d>KoM&_3>g$|KhkTFz9z{b=OM9w4cgQ*$$h{ z_gKqNk_b0Ys{N~O2}+bSfQ*J0?W-K;u3ZT~%^#Fn_X*5^cj6!R(!8;0u;eHk7WyMj za2%L6BtUmT*8e+jy}X?CQT4FYy((RgPC)l>1PjS0{x(CLIG|q9kFI}Ld*K}3AYK^$ z0Gr~k_URioC2QAKf(3~#N2?p+*)xm26~!pn2|45a!85Mx;Jx<1KvtrOjo{2>Yt~JF zU?s+KWsQS@iGl4BlU_{}!uV%?q;&Wi8a1AGJS0`WW0=I6S4Y-@I@Z>LT;j)#PrGl= zx`ELYK)LsH@}-GMmU#>|DcN(g35s*VCHzG!@C&@ppSH#VCye6;jznBr{=n+^H}JiG z@794|kH?>U=V_N2LEBFJOoZiuB5-T}mJU2_*Tn5{3q+A!V++XlTjqX^@*GyrnXwWA zLr>_-Ex|MGL~g4n|GtnvN=m9umks8hqg-3PU|qJ+tr%j__&b;Vd3EdB}ZUHkd%J3nc?ChtD~pAV&~5&hlDr-z}#4btV2u|WCRc>_g*P!Y6 z=Ad9!v(1NzVXmzUCq14<VrKk;2VDZ;%f(%rceltJy$Jy06=7PrN4kSLgnvWkH3-hfBQA=f6On- zr9pXRFSE#iE0>e4gEwjg2-9>`Y z4Sp5|>x8t{zdjy3ezJrj;km(7RuyyIp4wHBnS90=P_hOB-B*39gm(?f^m#wnR9Mw$ zmI?`|Ok;DAj|y3=8`GwuggPBsxqV=5Tl0pCVCQ))+k;oTZL8uYvWCWW*;BFmgD24t zqB%6`aVFZkckmvY14`c=Ev5nrchxwOngRNDm%MfHy+u@ahCGcUZI6p^LH13)^p6s4 z#R9j*e`Roa)pVNmd2}2t6v&*#7)byG`Y&Updb_~P6=Mue!l#f@#|}` z(vePX5-Yj16(C%Qg{_7qNe@rQDn(k(f_tYJD9u?cr#w3LDOy7k*>x`|@$-C%HTx)>+>2Zv|^A~F43|gIFYt1-UF>fTGZtlI6^zF>d-rlFCW3q8Fg`Q z9n;HdkZSfJLOSa`v3+(_Nd9hLbkA1H=56_1{`Mv5*Q$p%X7<`%eOzl)~A%6?*lpK>x*)^ zSC{jH&KbBFxhuUvc1`aSV}NlqaUhgEB}OePexC6AzJW<1LlTpdbcEETO5Mg}_nOyC z6MsDmEBD4|8A*lMH!H2@-^+DPs{hE>gZ;PucdnK~PV;mQ>F9#$Kno6ScuJ2>NOa*v zW%)F(S*Do=uctgEdwSI_59fS;7Neku$@Efrw|8;uU;l>qj>@c213CvNwobF-91% z#<0i4~&yOtG`J{*XO+Qq8shBd|Elbj{e}* zs(6Hs_n-j)n9k|vBMz{xj$K zJ{+S2Mc2_g4h;nkBaQ|46B3>LqH!WxJs`e*n>P?cR@KYn9Ri3)7}*-}dmhKq!*O%4 zKPyrEn@e}?4da?XCq*FRE_^^iiXlh5<)@>f7CXv!;dGjI(md+YtTgZ?GVF_Sa>Z3) zzif_v);L~@Q|lhmv2aX1uZq;c(<_lENmsnFYaQj59wH~rh&SVU1zm3b@5tN(QB^F| z5SFrygxd)5pVi;Z#@OC_GH)d*JcrByNEd1Egk&XX_~Pcj_wOIVjzXv{z_E(CV&KOB*KA6@fC7Ks zR;3T?Dc(6TbyrFe^HV%xlLByZ((akQVH=VSsD_-MI&bUH!FQ%TJr@S2Z!Vpjx6L)n z+OB1raciM#=my*DZURd54}>k}N455h-vhUNbPKR9dp|vSZrFF|gh;Agenk(G%5{f4 zs{#Wt0c|!zPos|j^Vc7_5XI%v<9R}cFC+3)odI9504Q0B9}MC2chf<5ngjoGhdY|? zdupStMc5{^%rI`gXs$lF)G8=7$Q18+E!>Ma)RrQ&rK(Hbv$8lsf-yE_&HBEa5~Dxf zX-jrr7)*TZ+0q{#F(7w`<21oV?gP_q!=;bBBMfKdq%2uVF_r*O$*q`bz zp5e+DP^>ST8&-v&y_}qKyWCyJS8P*eM~fEhAzNHsbb6M)NUPkVqMF;jLc&)J5bS4Z zFN72bOMcH{NMrMoRsI`cXzaK14iF0PC6QV$TZpByDkY@qfc_ot*&hD;1~Giw+Pa0E zj}7kpb)bD_e5M}gHeH!r6N7k7XO<(*f#g0p4HG6Ca!>1A31RS~&fw*+Iem}BI)W~) zcCkxHc&=jP_ls6Ku3o-T_{KQR0HV}I-{y^z>G*dV45ckWP2LUtec7Igga-05RR&(C2|`+OrJ zA_6#EAlwU}6`Mv~vM?)rA^p)J&k)&*5o}7dBOb^VEPlRew%j>2PNdR8j=IKd5L&nG z)=-ap&C$H+JMw7 z(xI<92qvI!k6UMOPHC6u<&Dtry?rgf>>su9@*rrL1^09ILAq%xW=dT(#x;9&xKs?| zK0L|4?a)UNJL+KTvk}T|Ub{277z`X>104XX3$q{Vtj25F@DP1?N31{ z-;gS0etKF)h2jDuj0{&AZ65#tWi6{&lKHrtk3e_;3%uM-!e;92Siyf(1-KlV^yw^p0g4sb4(RZ`endkm%lnVDoTBVW9uOc!ti(X@Wf2hha90{-s0# zh;{>7I6A}OV5c?sqii9WoG_4aZYtL54>i&*IqY1;RH)OQJBK@ zfRF&XG(;VMLi@+$*j{FJC}$Sjof>!K$7t%F*|$M!@vH06O@ZAnXkn7BqjlzI6Xo4c zqj`fF5n+u!D47MJ9>>T}BHpls2ukCxzWD9#rX41|l*%-Eo^{|9S@T$1Wvb<-^~Jg0 z4_EqqX$q4nb?x9n;~bwM_L7V}W1W@xmEh6A-`?7@Ta){noZcQ6p;NsE^JfQ67ldGJ z(+GTS--+>1%Mo?s2-ccHqpWOFoLEi!vh?|!ba?q46dBxNw0LyJ_Z$rGC;KMp%u1(9 zgvAa=3vAv`6pRv`R{sYDx-w_$J`1_cKY+@c;RKT04o3cf?jaR|+Z|wTgzm56AHWX6 zQnV-Rp3p+1if95gmVCwE9ORp7Hn-r;--Cl!J6zct75w^D0({pPe$%iC@k#g9cU?-V zuBEz+^P6_vkn`|){{h|;hIV+XVoQSW+2lkjIbCARy=n2MKEiIv*U)RJ z*SvN0;5eMxt}0RQVie|JWxAE7#psJ(q{>iEF~^!3WrO9G+Ps=o;|D6al$aPzTpWRN zQ=v~Hc35G$7jWWO)b%LLT`U0*P+mr(-wV4=x zPUOhz;L~&N4B#TUhY~MF{6>XiV<;nVi0r@qmQ(6lDoB?cBR9+I`%NEscHLdh<~gm} z<$JMnUvYWXI^EOraS-w3zd?eLe5b-81H-7NuAgFe(+(jIsu^z1I_6np7 zjZ59#eoXd{u_-S0OW*vl2mZc#-WvTKdI`nom6B_WRmQjWv>*n>5HR-Eyj~^`UrM!b zb5CrX)J$&$Hsf^?wh|Y_vy&0$jl3^kn?eE5^Tfo|M^ySfi6;nkdra#Q5)6`vSl*3oL+Y?@K6*I;z{*y-H9wen@+4;PCS{IU5; z6nSIDph6fnuSdcF$SgvJ0z`b>x5?+5oTMd(Zzo^MPlCBK7Nbd?V5w|ro$y(6I_bsB>*CImxlu=bM4rgLC6>}u0f)z$r`UO;8|nv>sE7M?qu;5xke2vPw61&vJ_dm6#7 zkZGw8@ibUrX6UC30f3H8trQag;sr&d7V!)IwF)J+&9imQTcqL;VrCN})})yUx+6nL zk{+0HjQ2EiuJ6Wzh9O+%b$*MO;q>i#Pv;--C)l|S<8+gR(r)3GnCM;2y)V6l=Z zu1@{4xp9+v9d$h(3<~5cVi@Rq)dJ)f{j_NKN<9O49KAVr?%eoU75Mue=9*$k`zeNt zsO{Uu>#u+mjQ5~GVRdGuJ|eB~x==Q6g<0QU#a-TEpoSyxQe_nEnw{^`N56>ih^Lx1jITL+Ex5(Lo(Y{3tCtoI=nvW5_zfu7 zC7=7B>5Tt&;QT2R{`TnQsxBnxx^ha~bUW8H=MBmqb`qu;x--<$$lG(*hcydlU#O2w zW7bD!aHiZ)R;P0G)6xd_wCLw2gWdB4CQAw%obN4Z89x{W(LQ6w!mEK~@H9O>1~h!j z%c!>n_y~6bU$xAs)Za^{jKi1ODkKk#VX#pMq7))m$D}LIx|gc_6QZ^ zSKe@jw=cDtpkxk|LMKMFpf?^OP)Jvb@*5N)P5B~FSldVBnDP4?l9Hh-gn z#o_oKz@aT>=zK65{}$4lIWyCF@9>e3-F5l%W2h{3AfNnC&#*6L4I{|X4oE-3;4vnZ zq6^p6RXE{6XzBWFvG>9y$0KLGORpIt49VP%9te8aOqR<=km|=C{#sq{z7yU zcoOh9VBq%6l-2rt-V{>|N4D^3jGiQnKkQRjT0&ex)z_2v?Qs;ECDtPdVFdQvIQCp= zHp2{%Ta(z;~7(wXo->VMF~K6Us! zF<=}&$q7U&ZzaA-jG#2(1n-oUtQL=fgXpE1z0dJc`&S#4BqKw7SFfrbe_sAikd{MU zS0H<)=h}EDVl6X@jUJ-L`Qh-|05oap8mY<*U9VU`B9$;V1vvs3c?fAz0AIApTt7<3N5R>^3JMPl+J~Fyci*3pj zKo||RAaNK<%zHEzZML{#U*Q;AOFruARi#92RScFtuMeUA+oV<6-Xh$~7uPkx{(pPB zYnLWkzdYK+N3IC8)wr?N0;xNoCrWqN3tJ^fO$Y$2Tu5zuIYn4^VaZFRj_bIOs;4ln zQGR*7IRXGsi}~6?k^`=zY%ue0QmNT2;4(cd;UpMfRYEr)54V$sy8~g#A^77gpy&>v z8forOw3uGAMIXE^0awJaLGK@%^rle*q7~j3nO&&fNTE*ln0{itUoa>enl{m*_At2GUsct>*p!iY(V@$v!)G`AuVE#TNP9AL2W@ zZCH&l@q#{8B3+~nZ8ahYzT#kd=ePlDj%Mbbk5cZ}z68$#M>Psj^x)ZK^1hDqkQL@v zJTZ|^{kVN@js86-D+rr{5}HQB?UjwjzZ`^!cZNt3Y0P~8^F{A$%d|P&AHfAV^o3e@ zDo{C3%0`41V9}V063mFnA@i=3ZV~lFH#;>cT1%87De880dq=SSd-iHf6f4fEvHd^3G^8R%{RH zYrGViLh?t+0@as;y+vx1BW2+Q!5~b)>)-q{?M*g_iI6i2G>W9h=Uc<2n=$(S)#AoY z{a1%p!<{;g$<2VHJfAaCAWkX_t<}V(x&z1+;=tH(6g<0^v9JNU%~)59a<{Q$bP5sd zfj+9TH2Q0o!$it*^`XIO4M1chX%;SkUe=wWOJ)#nyD&@fvZ1Q9qR$xjx{sr8o`jAv zWC|-GDD(66!qO|A;V?G!&O++wQHb?f;t{N{AOnK#?^_W8Zh5S;s}X%( z3BeTiuI&WL*SE?Z`S>WSKgGF6lmZ%1jjL+TJj`m>_f(!|Mqfu4Z0=juYqqN1zOxgX z))j8XY*11JN3f%`rZg|scXP%#3lV+<%ua)`OQC5$piZUBiYoU|8uPzW-LX&|?S`HB zg+R;LE^jvG_olS9LmJ%&Iii+N#{{(m4 zpG&C+jhWMf&#A7&Js1O9iBGcI2KAFZimd0K9ActznLhE|8z|v^RUj;i)3hc?^yH6? zUYN5I%~QKL9E@JO`$RS4vdB}ypil-t1@;A0u&NHqwkkU9< zzJkCZQ9;LPjO8=nGFzfi_=<|5^!2@>-)?f+(_^{$=nYYFzio^&l)F}jL20}~cu7HL zFMBP_6@W*BDFXP|)SPah z0IcA^bc;*Z3{>i)Lan!*t8W`mJCfZ6ijCwF6q>V85rt>^o*=4mJPsS8O1X zU~PsIBzbu}69(6Qp_@K&^1E>tzl}@%>4jI^?C>oF$ayn-dV0o}U=T|E`V?)qwhlNd z#b5INHN1;sz`P6UcAMJtx6Qk6Mm!x0_>9EhQ2XJ%91XB0jF_1Tm9#{9HsG4$jQo%5 zLQlb%I+xw}^_%O4cm@j#-up25A#^l5ZP9msG=li-e0RS-+Fq&_&=$@?|}hhF1B;NrMoEMtv@`p z^?#XmZWY8)C!Zdie(v>aJfLe@{`Z)e_gn!6F2Aw#IMBA*qS z#Oy9ezA@?ITE0cav~`izncYrW31ds`J3@%+&EoKuO#J+6g2joGlTR~`TF-CF*1KOE z;O=3V=sMB5x|7lqo5|;|&f32V8Q;RC)E>^8AXl-&%;sYaVeB{UJww@1r?1jG1`^)E zG7_;@K0Efg0xA8cARtxw@}(sy5Cqrh4?;jF=N%FGW!=XscD*E0OSy>kQ+|{|6`&L$ zCmn%ONFaw+jIu}4{2uO0ei!o5kRQb-rOb zNZ+byNg-{{j(`RZm*suQ2UhtC!?AaU?3;}-vhH+Lof9~z3llr^P6SV6n0P(Y;LCPM z&^=-|I!y5V_9*ZFc0GC``o<1`7v%~2`)><@M3eWJ;2u=mki_&skX*y&_W21S*qN7o zZ8-en7?NF7^{Mk{xFgu)%pm^UrSaEr+_}O!l_{$!fBH-KRc4T_84ELmlY)+oN8jt&8to$}q4a>SW<3E7niPWrx7B*vAgGyvIF?oB;FpPbN=D6V{3}qIt6P zAXi?1i~*QDn1YmN1;`5sKPnMQ+O^ofT#e&F=kfUHuJuJr+NeAqHw7ui7>*Wr9!E!@ zzcjqm@OYN%^GyZo2N+`?67bVdv%gbHK{ zv~{)CFB><)go-5B2wuB>j6T~@PevY>E7Zl|4g(k>`>|FFz{YMV zs@u`g`?XU#^WKi#Kg%jG7&xFFJaQFXt$#y)CV7fqK<%e^lbSSqq$u&v2yi9B19*ZD zI8t#8RSA|{W(I8k8NE`xf}sYC^gT8w2?m#N`&NaCU%s}}B2Q38@V#=4EKw=T~pg>wWTy#;zX4#`K^xlXb>Y@dQiD*vzp0rHzSOxem*ZhbL?sR?;xHKzhdb+B-XQn&{X$nwlHHU`;0RiJ6*mIKZzmbwn)8f~^ z=9qR-6l#5ZT-fSK?%86#tsK{0tU89&L~i^CfFISle`+)0A_CtJ=%KrtxQnzEAJ=qe zL}^^E+51NmC9DuK(~y>;c&QSk{pKMn$*!=}4CtK&D$e{QKS^x4Tf&p0r{TVs#mDLOL`MR~x z-jhrCbc3jS#V7_nUGsTHOFXJx4|+y$PE#B&2; zW*>r+F2a-}5^tgHK{t}Fl5#3jS;z*E%gsD z%kmrLfvwqZw`{7_TL}1n+fAVp$V|?I>B5_NJMc;AZFKJm>L$hpTFd30mkxc|s)uTo zlVIwJ$Ikn~TJxDB=#%7&R4-$zPKI^G`*rT{drhKW>rbOcC(ySsbJ%i&_fDLUd$oem zTFWwvAPfCkU)=@woIidl{5t^CN|3e(34I)sX!h_iY-wO~+O&1-tiMgTLJS~X7w4JIpSb$H#7pk_!WRjp zfxy8E%EG+f62+ZxV&>vFx;RYxL`)UxoI?|>v3tgualQsnJx>5!@|jpU<2J6V=gr|& z!p!m9KNKD-XBsx(XRT>=A~m^&ex>mFS*9-lv@k%i?`8F0{j&SbxCnV*yb^OEXeW;g zeh0`_P!TG%YWiEmdzM&_qFeC)Dr*RHEi@CUz#I_(ykwQGfhXyxfznCf~=mC;$_5{#;*Bxd$&?Jo|MUaXUXV-NUad zm~K|s%SKP-ZNB8OD=Ee&tiZtb&;P+cMDt zPA?7zwLzphm8NYK51tlrL6Z(B{mJHG*GO90@XBq76Cc$C(=54`Vf;)N=SKO5|N@2c2~I)$9%ai5Rar zfm+4TX(Q={i4NNROf!1dMy`GdE5(C@|G}DIHDTi)2YpF+5E=l~TtD;dtR>a6pDZQF zJrjSw+q~Z0zN=QnxYFEK(-wp;*H;EC(Lmh@QF(Jsh{e)@hBsJVnO`|n_99?q{X$Z5gH#=#WJcVEnLG`NkwczBf*&+caa2A=RpQS`KSb{wIM8 zX$DRA5WeN?o}YE&n%)5-J+jTqYoRM0Z%vEP7VwN_@@#_MyTs_*>#2>m!B^<3HdICb)W7&M25iW{NI&A%>SoTr0rrME}Qh^aSBYHP$8ez*49Vtw9CiUL1*!TDj5Z)FdX9{jOL7w)2=GuyId)WTJ!u(eb>6@ zhv5uYZSMZaz13K;1SrB~Fa9<862V7oe37HmEdhVmEQ~K3=V=O1Jf0_ON}ktIES+l| z)f)r$gzEt;R`UeNjuNtM5zqxNIJ~gwMFPT+GR>jh&`bcwIf+DX68r_&)}xcitlqa_E^T zHp}e4RS;Y)@G6q9(vbElkHjNt6O8z$PF)il2GNje@h2kR?o4gK63E)%)MK=hC@}2*EuEJsu)j# zePpXB(e@8fK6}R-8*i_zu6viTJui+9(n5#6?$7`i{k@ui{^38 zWWt#Y9d+Yy2xC4gOl(ucDE-^kxNoz?)PN~}c2f%$84_ZNacyvDf-TjCBuP&i}?K$zN7NapTW#9#%%Towl%yQ zQa$z*jpZrtzT+(X1TfLK>sXy`@A z{#$D>bUyJ?V&Xm%TPNgc6$CXG4~Hh(s894>VjcWTL!eHB`v;#>96kj(cECzn10m4E zU8)8Dt1+*{}^m|FSnG-F+cR9CgPdCzG}U;iM06Aj%y$X%l5- z>YA)K`0Rvf6IN8Az6hiI%Z4KWxtd49bH<6Tmky>~a_bCV#r=c{4o}cuFk!oXZ1Xd&rPtvlrLb4mN`XSm)spfKdVcQ!-y}30~ zk{U;^Th;KFgU!(Ob;9xbuIXb}?fCh;o{Bq6jNr!DLqL7kTzd>jRuGN$vm}>S{q*W8 z4THW;2<`y)a*Y28LEcQS#Cl-o%po72OLD03p@vbv1E&V}#bf#Iv0rOo&|0n8$tw)j zZ#k`xv4fLFoTB|RTETtn3sDkUZJoG3!d8>YrGMx{WVk9T+~Rgdg6shR1HA>0$syXn zelgG+t;kc?;&Vhu$k!tB7k8wY&k}x$NvJ8iN{qM)NU>y=v_thkY51n3@wh6oT-%*+ z>Ja>i$Wfig+aO(@XTN2SG!Xo|ze6KKT28wpX(+Nvq_`|}Gz2s= zZ+>n5%xp3IwFZ;8un!xAzS6Is*LUV-C(*Ln7mnN%cw!J}M>7*_4Xrx8%iEHIJy`1X zsl98>VP2L${8}8EOTRX+ZsWmk4Zi-HTxOY__tjJ8ADb;Yi8>usmB>eD$O0$8Qqz#P z)Cc@|RhR%!#RlrO+Z8-w`$m>`!R-mX;p5~Ebmt0gnHuQL9w&n8!M3!ji)SLi1=MTS zWz9`I(xN2WPc$s>9CQi!a{)kIEx8Y>T4M)g3b86^>2z|QW*GuT=u7C-7~X^HAGdW& z<{5W9cj2!F>o{iJf;3Ud`bhMw5=cwg)U+zMZOI1Ul%$9jL>s%ijkdgkcGA<})Ek{5g{BOjf;n3R6R&=E8K_qGF zEJ2X-sRk~~0vXh{LXj>1#4|tXf~j#04E}zJy}GfU5M||gTkA}D_HfhkWyW=QV#21` zkf^h)dTINCR!@j0B*NM8<+mcG)s^6=4?lw}K~bC*BJkbZHUGHH;BP0%fqIs9O%Za}Wz@s@aM~&wvgbf66XxV`&FF@@*%A)LQPd3gP5G9^ zKy|gPwut*QZuA%1H0iD*|Kt{3C$N1W$~^dfKRqO43k$-Vev|1BGV{l)C8aXI!5Ds& z3bt$O+>$wF*lC6u_My`h&k@LvlavXtMiRg_tY;l>@^ck!+&DM}%pyovV%<aqW{jRoWK7f?_cwkz-B_=gbic~a6>8$CvaX8Z*h90Nhd3QDAzC$!)-=_!e znnSn)IfBrFwvh4VxynFB^#*Kom>LxZ0r-;}a zzDu`=xG%#kC;bs~y<}Umj>_JfgkzPFEtFj-k-f6Xmc3;hGkaw_$2s4-=leYW!1>{JxvtN6 zzh7_A%;a{lecEG9#2nrsV~*=c(4-(nf424#gIaUEYMgaj+Ii7&rm{!8N58-xxTGF5 z)owh0Q=bfXIpWs_6cOLll( zAo$e2)wEPq-6{4rWdx%11$E6FbbIoikTFD&=P7eh5hW8!wCjV#?e6!P_@Ftnqx3OUI$qaY;wTM+xMAM_doU9B6iST zTNsKoEOURJ1Xyj54iOH;>1}Qwxk|RQH01_2aeyMo`nz{oi~I5nT`u6AzK3vzsgo1T z_N%kMpxUfu_TMPvn+exG3HV(htpIp3!^iB_&eud4F@fthIB4GeU9a@ihl9Dol^tT4 z6i4vfu#*mpMl2vu1khF! z_XFy0j>veSj$vIH?_X{<*G`)W57qWCs^Enjf5g8mqm1{&fq_FXZqC7lKKF11Z1jVzj}KD)qMO4O8JJYXwiNC?0c$zOxM=Yx zNMa%(UVFedZQt6L$m^W}NN|lv-dzUT94Z|2!r0mM)KDsSD0rB64{<>L%kxnL7JqrI zi8lE6R_DEUBjRTW4a_IKi*m^CF646cHyT55upM^t$U8rIer4$9=4M|hI5V*A(dJ!+ zK22c&IQ%5?ymsZQ3D_bYpH}@AVc0krN+qdyj^>D{`*a4*3CoPR!92X@UiUSSpFlKC zcH(f~!oOzo@Ycz^8^r_yEgkHy`Q@ePQ&@h=1W*7uV^y4cob$h9adFf!&@aJPcu&nI0TilzLMf#_sdJC_kL3j-XN zSCY6E^v|hMD}xyZISTLmRqu7PbbPGwDr4tvG{Ya=4gesDcnT>D&`C`*CpxCdFGE6K z@N=I<8sGNWZ!T@A|LM{z=J^88_ddRCKfY@OB$9H&&+2fI&(|R`+}&6f*7g)EJ#wI@ za5cHZT_r+b`OVcTA2!!dB#&YiT+%oy9adqrWPZaz`!|fQ=*6U95v|Xc!KhV-`#+jK zE(T+RPZAD6zo>m;(w!(kMBj9F?{Z`4#$Sjdw{Exl^X%=w+Qa~|V3r=NZpmWp^gRsq z}p#B$)K zqC&Tp(M5PEpuzFDbCUEW9z@<&2jXvdKyo_d-mna}-zi zA0^8)Vg(P^{8ud8f}(JP5%{$iQ+h*?r(Lg$ZckYf*%N*S!p(TNG%44FBr|HbdH=FI z3WEuNUO<^k$4XbqW}hmtoYlR*@6DJ1&f1U&W)qQ5oJ3LFsr+e$-d99q9>av zbq|&m@U{ZTDFTBU#Sm{diO_d4Ij>SgAy+k>Y2~DUHYUp05YZl|PM87{XRf24H*NCc z7pqo#tXV(^gL~|7k{#dY#P@fEVl=4$Tn#;a;u?O~X{-&NP5<<(<48%-P)a#p*?lC% zveFgb) zpK`%TUt@|pDJ+M*z{C6hPUp6qJ`#qBu(BJI+#|}ypGcfDCG^*wOXs>u_;d6lDt+kt zyy1+#+jkAOLAa2c?a6#oCaS4pNcTNofzd9?!KURbVdQM>ackhSjV3hEUwc9y09d(P0{O_HkR*(DfUV0>mXJ zA*MKUGgs>8`(4#CcONABLZ=rM-eLJS_10C%l6jkV5djH$^s%2YOW4<@2x&o}Nf<(@ zyqW5w4Rgp(j;c|9<3hqd9UL|M+~9?2wAUc(Psq~`%D9z0?n<#Mk1RvigKU}V>aZcu z2>Y6GtBmypP zq3>O%zJ><{6?$W7!5#%pqk@}`+);eQzCY^=TiYO*r9OD%%B;`B1mgiW0)4uw0?G$pxb=q~ zJeC{>$zEAHuK3lao_EJgV;X}BpMx7;0sn^cN9EmX6X$0(4HhlN|8`s*`x@q4frQtk zay7X}{T=(gIM;74usOT`%-@AnXLGIC65q4G-ML*#wjZ&%Drt%EA>vE)t3)MM7{Agp zslKoB1oxk#BIxg*{^I1s_;avc+e6E6DGA3OI4u?8rarKUqUS2T^Uxvj^PME-L_;!r zed1NZNSq+A6lcG+;KIr-4RZji!%MupmPJ>qVRQ#=txoD6BTrD>`4? zdn9Xciw5GT&#bbX4l>Zb#_;oJ7j|9^IfJ^@;2#fNd*rnAY2H#5dFdN9FEwta|NUo7 zaRC1ePhKtGl!N~!`&}FDe3y+{D7j>Wv)vAI7=ye^dAYlPS$Hl9lkCm%E`ezF=w zJf-#L+U$_4AaACz#djyPBsZ}K*a?EfbFMo-dz-wAii%`l0`2y??|HQ{RJA~7Tq^mO zPnuEOPoMQY6nXgV_G>r3?*tv8v;5gGy_Nbvqs6Q#)cMr^fG7AsNIDKgF`WhEQ=zyOdViDCg;8KEYp|vBlXnvE z%YfUix5@xGvB^)V%jN5l(WeQ10i5?)b>?qMj<#s%baf?IVqM*AtDBmR!Fyd=sxvm$ zRWf=||4H-GkMxOw9lE=+7-g+0%vPHpMZpCvk#NOv2sTOz7@1pwy8o_=^?d06{ z7N>XejAXF@xRMOjKgz}=%Nly&wjeuLa~PhiE#}Rr-%qhF6Gft z%wuMyo3$5@P}~1&_*zg{L(EoF5|-dxDNqyRNG8*Cj#WN;7ZCC1?dmtJKjFvw-qKBN zU>{+3;-HTV;|5Eu_}%sgn1a-r&}?l&{#|^s^~-q@9Ed^-&~p zPYPymse@m3tz+NaQ>G<5s44e9W)k(3PcbhF(h`*3z;*&tM|j zUe@8#wcoMdz2E(&lh6Pz)ec@7kRM;>Wrb)ObCeE$HeX7(EC;kGA6S83!r)YW67?WOEl+|<)pz96~$~Fh*|KOu11_96m z^_0P@c-~y67cURQw#WhT-cvYRO&iYS%h)eM4Z3en`cKa>m_PHK%dYHk8bZ-`Mv1Z_ z_lFhQrm^VCpkTATv~r(H>n5u7dUaIorMUM+Hm`-O9F}!YBUL@-=N`u?SmnK6BImC^ znZLT}Tu0UKt%$ZVmGG*UX7qI4>mvz%_M{Cvi@6MH@P9QVBUa{*!xF1;@#13H(}NF} zz|f&OxT!T)V^bNKgl^5e#%Z53H*5xg<%!!J9F;d$-|~ylx4)vWRV^J-6AMl`=Od4U zH)05`7Yx|Nl(qy-OSqOsqsg$n-D9X394gA^RtBVIoGKFhpZh`#L#1#t+U+HodGaY! zPm1f7S-l=ic>!Uw2K;^ZCZ0tMw?Y%-iB%y()V={paz*H*t!qYdD;rMV1YbLj(ZF zB5V;P{OXZB#)PPt_Mg%q=8s$xORU@B!I!ommf)s@uP?P}B$*+g=IL<5#{qGF0~En* z>^`e~?K5Szz-3z^Tz__mY{ohHI>v5a5GBx_z@;W5l>N1H9-IK7++?1gBfRoc7*oTn zgooMB@!D=Lta)M9E~a)*=3P$=8Qcs@+ugbkyEnjoTjooy70XIFu3DA+F`Lpbs)^aA zVRQjLB6C?U{-+ZD<)(6T#S85D*5j7HVvpF0H%r!)I6iT*Co2|w94DzvWazfSjWaZJ zD5OhP(dA+SYSc@K4DRvocAK6rNyrnWyU2d1c+~DEo)-sny7mxSLdLZ2oagJp@c@Y&8281gct%Fbn^GK4&F9-B|P)6g5~J- z@tYqifkfsI6HXtyb)?UYl6&wv&LNf!KP2}KB3N!4Tir+#iuK7E5$oEyVOHAt+MF~o zLRA(*c3B(QnJbZTWIyQ@AQ>hA6WLO$wg z$L!#8`1OYo5!Q+aPaPpJ|7>MmNViq9swH>n5a(m9upLSg7%U`xv)cFgOQ;5%5PUD zv+XKiDiINOlRp1?=eYexTbRstF@377_opGB9V9*^@YnlYoPdoo=%W}5EKXkE zdd>~5_Re|~6pgVLxlLbtH*Am1H^mHZgZ{n4b}$Zl#p-&l7EKYKOI~osoSq=q#$Gmb zluVL~om|G2(l{l^4FrCbfO92<=OJaHB=kk>yCl@$ninMWw7&X%I5Og6Tlx(&J- zk;s3zg~ql7yO(sml%9yv@cZfMh1i?QfWU>av=TYEd&`R^y5XdUE6joN_5}FBo9G8a zrxa3eN8xPe6oNZRTCQ(X=JprS^Y#4~k=}|^rrN4pHZ>n_#AMKyhS-w}^tMN>Wpf(J zqzu{a+U2tGL5|fHOE8~1dg@n2*tm%GKx3mdP4pC4%Sn$KgNZod8b8Mqc(Hsl-_fzj zw-`h_b8XQze05biS7=5Dz$}pztz+$7(9bw2x5oh! zge5lq2|BoV<+7_CC}>XNPo)Swdy-BH1r~bpEQlAIc)L5qpl_?iD!{w;Kj?#9v9G|2 z&&P9ySS(9b+>SWH?C&EhtT4{dse*cHqPG6mqF&mOkx2Fnwpv^2j;CUjaz;GF*N<=V3R!)DRv& zBKET*60Ii!sW`ix$6pOU)f_RJ-TZ|E{s@VcnaH+P;o^?KrGYe80|Y z8`LJGmk$J+A^%s@l2VBW3$da<;2e#i+Q8R}jSP0{wB7)z&gI2mOe%z*n#W`q2(}4h zL;IcebJ#1FQ+F1MNTH{PcORJcJFbD$i{4H2#;XCcLw* z;nQ?@lw`5Aw@#rwK5bmSw{*rJ_y8i?651ATr9|Jsr-*JdzTK3KcvX!>8=x_$fU7zI zKd?I32;lO?ab;L1SjO2#`a;`SVVYMi6x+NzyZQSg^5X9O(Rf(Bb6}Qxj5#(k63rtK#M1NjhZnf?S&Ta0Y`EKj&HtfWx&_ zf%J=vL55%8|dKqFt%Q$c7s z3k9N$n1x*`w2sv%39Y$K*-OldwX+DQT$8(1CUphVCX>+H)hy~q~ zn8N?e#z+__gO~W0H>OvdsgLBb9FD!Ar6QN&X9SCc02Br9&7;vL+K%wXw=G)L0>cb- z+}sW~FD~-uBtiay6g1CHz{%7FER(KKYv=nu#(VUwW>L?=k)gl%a1T67sualgm+jGy zz}bWYXr;i_d20(esvb)+5A#>g-E^MLcls;F5#ZR6zQu$v4J0!>M6G@E4=@w6MEYim zqu|;t&?cICob+%p<~D?{?ga%sXGO@5wF_)K8YjIMizpCFS8^T^Yq8!`y5F?6XMbhL z4+L{UEn0;G)0y9L3^P09G!`vVL`6k~L~)m<%yvafCm9k8>PP-|&BiNc{>7Z7K}992 zf23gL^g)=cPL|*y6d=4Ctir2I3xgS&^;YU73&f<}DKKgWhMbt?`yaNA1XxrR# z!&h)7RelQ?2ffd@M0P>1m3sKwM+O5?yvs_~?qmPu$$(8l^XN6`h&med1hyq{F55AO zv)IR^a?sSzS$4YHi{I&Tx^!Y)s=j+Tk4Fgt!NXiPSJ(9Mk(~rbZ>6=rwE#4d2 zd&CR$fudzVS^G1q8A2Bd$xQq>)>#dqM8s%;J${~s{THE+6g}WHZ2Q73#qFZsx?l~L z?)erbJAHAurI|M2pLS@&_r`Fu-0*g{D>%kI=wKM-rC=4o&VNU$qki40TRnUnd?iPb z>%@OO{?G>-c|s7p1C21z3<-9GO+tWP)PpTj6vb~q)e%mItFH>v7nB%K<&TLS^Y|X? z`b{MYS0H35-)H@PfLKmTg*;QgjI7#HCX@?5GT3>aK%5nJL`Alq4`wS1Xnp48&o>Yz zbuEZmZ{8!B+b{U8@Va+#Q1QGy(&xqIDjj4mnREpyG_zeE*_!ti03Rqge1~1p6xuA@ z7>0eI0gM)6Qe3O*#r3iI{8taZ+T&ZlwdR-02CJfazkkT`Q+}p`0Y*;ML%s?uoZ;Vg zxWPOPRaic4Rlj_(`tUf_J!o8jb>3-b9V5g^CgiB8VH+_P7vq)!?vLb9=PrNe>-St; z@jnu0hXVc-YFs5gH>WkPgUw6n-MSB^Ox#|~Q2T4RQp9Z_$XZ=)I+G;BJIO;Yd7qu; zQv-@pbPbc%ecSbvWj^S@i_TdYVeC~X4xG*fvAszpcXC59jkN$mT(}P*6g6LYxbx_B z^!YrjtBlkPQugEGw3WJ`0-IxUl~N?~5sh>cBq}nAyOXqA{^Ai)nuOzw)ST2prAFU% zvv|iiA3MZ0#^G_QZdM9-n7~Y{tJ8w?p|Z|p_KJ~9S2xDdxv}UCN3U5pyWNG?Q7gvp z;l(3maD=!K*EYe2MO&;VGhaGmOza+pOx7YDOkvL{tP;5kK6q=}T7CA&img(XV^no0 z_I5%aG8LPhzph`L?pE~M(AxvgVJod$$IQOTM;7)hQ;W88r<2CCrVnbcxr-UnV5@9NoPeDD&-N`Y5_(+G0g6Mdj@nV-- zPm44B8WssJ;*K8{(*HfB?>AEaCj zl1dc%oaBhc9VWtE_P}=6Z2w)L({Aak!Vi_Om4tGTrEJzaB7WKH*P6TdsLZ)`!_Jz0 zTV^w;qx^%cg&8KW>?W0mIM4U{K80?22flp$$WIIuUf3u#R)zcZ$M?`XO5 z^T*S1cX{OP!B{)2RhJ%^@@1AjbL(kcl5p$VP~oe9C8)gosDWnj8z(ZVng>e){C4O)&z!5{@G z8rEC(UO&#!d=*rVpudZ**E;{_`TtKFD~}&(*MPD^HY$$fzw)~^YH;)776ev5*7ipg zq66kn_{7*o*y@bMX>E3{>^n<0H2wPX_-U=+$jY_;lt})c|PE>)xRL zA=8R)U#he+d2yE%6Pl47E)4_Usk6k-L~19_fv;bK1#kU|8%2MhV-;+%Z|=8i@B}cXjIEbiK1Mj-X`p(X~ICk2fJ5Ui4p``_#-xbhBON zWr9+8ZtF$X1P~#Ep@FoAp3^|o@<@vqjbr7y?FnsFY`?6UN;hT?eFdNYrdQr~4IH$! z!^NM1f{r%P;;);F^Shq)ud2I~7xe5CH-Iz4f1|PKkvjj>072i0BMi%m+d~NT z{2XvpiRvxed^B^l$##f6u||$ubp>%C;lJThI4u%%xQMVjgq?64j-pwY3+u6=cKrA?rMZ!GoJ}%rtCD-1xkC0Nr+< zYGw3`m)tLn3JkubacPF&4!^W?H?8v0`#}#UGU2Y9yVGiSdVJ3ac@u*cnY_xkp$xtw z{v#6d-?3HWC?;0@X}2RpEQ6Nx^Jll!yg6IG>LW?z2Dl#ju~~I z2sCBu*dbeW+1ab+;+Sn|dkG1Ll@0nF*DNh#HaDmK?w8Jr{jiLlCt>)RdijKHc6Q&d zCzO_ayTVHxjMczB)9;q)Kk^L#DfXzndbj+@CVz=>Ez%f(a}Z-wdETa!+NN}1nUkK` z{jde`N9YHJ0BT5NF2!1~d?QBRUKt9w*;Y{ypZToXUf$gr#3A$kdU}Y6VjgvXS ztGEGW&(v_Yp91~zfcq$+Ok5o&k&x|v3%$O!pv(KQ0$LDi4^!nfBBtzm()zvGE6swB z>xr1E*E@iiJ?qtxQBt5J&YK64uuKRw4xeh$m|T47ouP+#f~1biRS}K0PTAG9?zhXJ z@chdOwzbgkE;5^=mMrz|7-y z5%8Ncj23NsdN5GlL~~!sH!=Q>Kpi`5=xJ`)Nj&GPv0-6AP1~t7)*z_^q!-{8g3PsI z?SU8zm+aCmd4T>N-;!o8sib{aCJ#{%q8g8~pyCB(6ccrxtb|;&ASmwOgsO!^iP*E< zyXCWAT01UghzJ3a*E9B=0LZvGJzV9@eZG_ zZ0(uSXkjo3`HWK^>l2U}=hizhcXM;eL2(@DI8GX;u6o3N-q76K02-#*bEiS+OfACa zOnZ+!kZ37P$=bJ3Pn3f7K0>Y-2S<)8+Yl7yO0pnzH|r^oV83L2k_k!@3_@^4^wX-$ zP@6IrYaUp1joEGZyTuY=mNkUC{1KkJAc!ZPU&p+C8G=K${hs~E)HX2?2K~D!2nk3W z#R+*Ninx?KLIrTFgr|JSN_NlVBlJ1^@Ox5>nv;sw0G9DyTAj}zYRgd{hf4gVO~>?$ z(vscZ^1>Y!47i~b-g$Z--qyS=a98CV@}y28)FNRHWdx`G((0K}HR>)Icvepp{nHx% zRC)UtzcP^)Zta=6a5n(SMn9Hy0$S28yuOS|8RfFu{y+0`hXs1sWwZMAP}t6ycBS+^F8sXB){Dt3z-8JYJVOrAtTjRBvv1YJT(hqFXWTT3ni!MHfI zwxX~0C7P#}yiWdA@QG5+K=0<+&VKDe>yN*>#dx!1v)PyE*_(%pzF7LamnuSwv*jZW zN*tSS(x;(tV(fKo`NbKs;|7Hue_I|AwIW6|^eDAs@u&kws_hdYJ_*O)&s4hBI>}p< zV^c?OaX*GCyds!xtg7+ZpRMm`F9m?`nKahP`o&Ic5$p3a2)Ap%gcSCnTiSkhXboZlieiyPsOC?^MR_?RnO~4z8VFAiOW`w_o}OPYZ4_yN&M3$1cIqZBta5!M^>!?q#<(@t;|c1D2t;j*T{chRpig;A`ml zX5Xj0?c6;Rbx(*#@vo2hpKaH4=lbJdw*l}1QuB7-EKvT!zvkb<)YJd;Bh)9(9Fopy zrjH}1jMt}oa(ng;)U`ow6_-d8M;=s^{G7Ki-J9MGjh>LayH?3e!6^Zjo{(Mr!yD0hr#n$Xzyk^vM#ja1;xPo_%5vn2A#XwUydOE(9lV)8EDo#} zJIOAo-}CJ_iEIjm?=8I!%Hg^p3A82sOMn!(zWCURv8nC_IdGi}SA)7g`nRl*+`jXD zw#|6(FyYw)^OkSFbPd)~K^C;FWgQQz#r1Kubi#GMno{3-K$p-@YEW48qKi<|k3M1l z-FW|VPny0V%fQz_t1WysAa@hBUpMQz!+kLw?KXuQ)2=GXxOFVcOr9d3rFCnFxmrt- zqJuwjTitI~nS-8lX3ymKok>6fG<1f}>m1}pQm4tD~& zcDzO~U!2HXg&e`Ec8Z7HpXhTMZmbrqxx70U;Fomve3#Ef^6(hy(aSQguCylX{^p0m z49hduzc*k`R{d*}sX~)dQ+#lCkQInFz;HJ{u>402e*}n+0VFl0m8Y>OR z!VyCMB~jEPQ&Ed`0}QR^?k$ie0(eyUiu)QU zG~%Vqy$Q#BW*EKPLtIDst+hDi zzz5OCxKO0SD#TR(Fj;|KjEW)GZ9O!kCF zYAe(#R|06u0X)KxZw}WRqc=D}n0O3>Z+opfUMo;<*Hrq;yG>@d)YMJ4fX+sj!i_c_ z-%;*Prcd+mxW)Z;37ml>ZcdW*HzH9`15Vs=A{gy~e#j9tg;?xvpkfl<#eY>1VwXI3 z+Tm>IHkKm?~MUk4?d^B&- zvbg?_>0M{O-%Zy}r@2_S7%9lZfuc~(>wF-riZ{Ti1|!-DmB^RPB>iW%7iaAWf6pDK z3d0pyy}w?Fbl%+nG4Q1&FShp$_wd0!-?0Zf{w1i==_%~fS#KH9@-1c{)mmoqzw>!^ia;)BYEgfZ)o;ff*-I)HZW)}GF%d8H ziHbvG@aL?=KU&Mk7^HnW`ohBP|IScazHQFvO0@h}X^G#e=lbv^wjHCij(K77dNdJ1 zNtO&r4H?j<(3R|eo3icfE&r&8C#9?C+Z4SaLB#P34F~eu&ssxY8Wi+XIeg+y~$QFH|xDSbzc?S$D83CCKuA6LV3H<#DYhpCHQ#aLBEjyEA59BDNkE_ffy}OBJ6B zP_58JuvLEZ|0>F$8w39krEN@ReA`)v?BBz13-yX+Qh?L?*jA36e_Q-l)rtVKtudv;id&QzcXKx$&L6$dVdPVP1w)p!X4PijRKTgpOU|d%x6L)6%b!O4$>?1_$O9w zECS@EI!VKkMSv0c`8|=D0~Z64^vp$Diwzd|e%F5Y=IXB}uPL9x*sczfpx(1;NB4lE zBq*;5&FkxfwsIx4of9YPPt~Qja-`1=TgPpd9#=C_lA<|7tcbKj=o8yzJdCUx_@QT-+MZeg zk> zW-pX25nA_?Sf6WVnWRGgi9;nzUc(*R4>rgCnx)%?XTfv7lS}qszeFr?eooxark9 zN5w8lk)Aj3*;|~M;)~3-H#OPa7#_k{9^i-+#kxd~xhk|2J>f>aYQo9INoVUp2;Zkf z#GhhC(YriANSp1;iMEZ4Gq;ZF3Nvh%CJYM|Ud~z^Hg0-R zZ=trfBJok@)V+v`D~8kBr5du-j z!>QX7_9!epDIWOcXk^@4rYx*aY{m!Jd}Ja`<{f(9A0m;bpigd{!4z3E9PaBHr_|B= z1zz^@kkg!wV5#X1sAK#Wn!B4|Mu3^b+hUa`ToJ%_KlXLY#E{sB=!^ma*&k%zrHI7y zl+r!7BN1I~DF@l!HpX!E>vVynu3MKG<7ACq=pqdiu<*200ID7s>()YaYODOx*VST`RGM-saYgRx{Hm2 zc2M?O=?)@PMUzJK`1w_@(t(YRE)+qM37Om^w{vX{MN zFT}rwB?N|XFb8!ASRT?%wbzIOi``62D`q(Qr(NXqWyjUd=@RpcShrWott(LF+puAYjC`ba~2|WOkP1PG)L!Syniv}xEc9U&oK4{aY~r%TV?gNPweE$yi9w8?embI zdV!=u$xj{vTM$Nla5};n_PM9@k`%kSC#N?24SFQGrC+voeNj&F{~55?*H^F6QqiY} zjoA1Te%}WGIq~vKO0h%-(pLfxq{PQKe}T<0GpRSL!P8oj@Du$h^RzM%XA8Qr!Isjl z8Lsn(1+`ZxWpm^;1bItWFRNE!^?SpV7yo^eF)kAkgsgsKiQ9Sei_D*1~Nr|ok zbHgzufzD@uD)y~N=a?p0q(KImj)bQ#K^4t!5+=^Y)`3@6$S=_Pk3gLKPSgD6I>t%# zM5#)A&qeOPAzlf3hZsV=OM41!HZe|Te{7zdQPx{Dju4I%Oe*Fxc(#^w2Ca3p!eV$A z3@m=RQe?&<4}TR8whVQrF5mY5-KN; zd>$ovUklP4x4#7k&$)qZ-S_s=j?LwPo=YWr3i3Vd=ncnH*z===!#~;Zzs9(i6B2OL z&Mxw=Ht5U`+rTjcC|wWB+~uS!ub!65(lb*d66Tj3#bUlv6{rl_^r0__pmOwZmB+QR8rPheW+k z6)^$tx%$kX^!MhW=0s&_Ur>IWWkHXX)=@RT2zXWL7L<}=ix|BflRNAsv-Aj3GI@W+ zjuYPIX}>3+Z7Q;O;&pu9xOkXWW*G_(N7e#)Y35R!y< zFFIyciJxtPw~RDuN_PI>?8l2zEhi&f^_Ll=_SRq|;iBu-o`B!88m402uEgkipo}K4 z46vXfbhkw#&hbpk9;4UZgw$a_z$!6n#sBZ+MP5yvJtp)>L~Ey?dpQCz#G`pJ`f5cl z@}@CLEad8Q_KXw+a_`29{=|c@Gqk5go9MtIe|d>HsIQEk^en)j$anutM$poEgL>K* zg#-ro`|#)N-^ai8NEXobCuAuTnmb(deda2ic_04!1-P!=BR3c2vV707V-Sf)yWSRw zv;0_4JQoMZ#SOhtW=X@i)Reak)992%QS^erGJYR{1D%Otg5aR;0Fqi2Tr5$^WrHU=>v~r-wS1{^;^j@$M$SnF%}XzywT)@iYzy;7s6cz z&c4j9AcsDw{j%YcY;&?(3F+~L+>5h0~A&F`=QZ4)2k z>1U6#f)G$d@+kBSMjXLQbV4Da3Qf-eC=C|1RPQgAzR0MtZ0Gu_A@Eh*d|VxVKFZB%OF@z?cNpWn zGralbkmY=F(k~M7nx{(j^yizSI#O4hf`>Ma&zxhbz6)8ikKUJb{RZFpolwe@|xWo?Sb)-QV5EkJD1> zC$Kl04G>lDr5{Vx+RK(SKeCXSTCL321WYF0*2+Y_lYLI`DRPwG9&;6_SgX61bs-`7cY?BA-#DYH zIHq{I4&NeF$o8D4rp{3G4|V?S5S)WqDT|5bwu{5Vg9Y5plo0**9Jg;z7nT)&77%_Z zJlS2*z5VT?jT?0rl;0s<_ui_f^IV5=YLNHE8T+>{Tmq{-+G3j>3Gewf1ZOEPdT;p=X>GFC?N|z;&=TpiuFNkLH@4_B!{9<7R^aYLH zzE?KRE35gYYrtS*oT_GE@1!rxsS_P}@<{!R*>0l~Zq*OR80nR`F)wyY>A+BW1)AlvHQqUYmn+Rfjjd_6WZcVd-4wp0=Ov^MG? zOW?ON+&gjXEm!ZtkhR&)4QS@kK7D9h>No$Q`kPOsS}m1vxTqErIA+$JwmLzYaY{f| zAMi%`FZbaeG=fJqqq9;zD{!Lfgj&T>{%N?{r@G7+^}@Tlg}TuLhfyD*4e zWU{=i6kYX~iRf<5h(3<|2e`L{65)pGL&YTEeM%O zAh-vz%!j<_G*?=~c&&%6JZN&#%LhI*obcO~U2cUuJGVn}H2!gylZId1V7go={*R`s zaEof|`e$b796E)eq#FbjVd(Aw#E+r)d1f@F!X{4EN?tAa| zJo5*fIcM*+*RS?sD$E|V|M_}n)3~dh{Kw1kXm;J_`V`uNB3rmq3}I*ehC0|$Fj`&!T{Y6Qq}*P#S){UYZho}PZLYRqhJv;@FQqk76P9Ad+CTc~FJ zZ?Rz+!0tepP9?WXW$?}E_^b=d-7u0PWvAh^`ubFNP1iSVt_s1eF8Ctsf_UMPOLiPj_WhZlacfj`1TVVxQVw;)krWB2lx>j@GPi)5>s6GE{;tt8M1|mlmyQps%4Gt{$e}EEMR^+ ze(rrC?H6apkJo;J5g*3h-3~;;{Z$UyyPt+8zi)F!{LBfwYw+{C*na54Pm*LUqK_$w zo~qba*9Xy!$xn zVG4blnbk;?de|rV`rMd!^CJ+_c}(Z{{rd3lNBBn)V_JL^ZtzslXu>+b;}6cJ?UA@y zf?8QywKZQKre5LqT?sFwm%tRIT-N0!X2;j2l1`pF@ywxx{g4tvq7qk0uhHFUZ|nnR z#-@-7eC$;kRchAQRrYZZW??9Kf}*XqLR(FR(0JybgY%->-5&>D`g0g!Ck$g1?E&Xq zw{wRVC6yAF2k{)(ob%LKU6)|+mpXNY8Hx94oNbcDI~TtQr0i}b?)AP{vGj7)ubP+|OZDYJGG=Ik zuS+WJrti^NTb;D4c%BQA&a!VGM5A57ZzN+$O3=+9OFRwE?qLi-i03>yjJVz`y zz?9z8FB_YgHWj~&<@i!Y0B?N+hH$}CW+=5X*EWq^#B6wB!8oCS8F5M7e%z}6Z&UA= z-Y=Gw?b$qf1H-$vX)vVCCC^Wx2EB`kGF-|A!>@K2{!K$NW0gF^7CwRBw0lEC?uZ$N`|Kv~m# zYzq?HeZjP>L<61j|~N3z%UB(s*T8J zQd1f!cEKr_B0>VWFEg2zRzSami0r>$q<-gZmxA^ zp3s28o)8WLzD-=XJ|0?yjyW9$^yPt^Sxt^kU^irWD7Q^H5psse1Q$y#pc*gxN*>-`|LExO+}0yAh2&*~qF8n;Bn5~+Lflny@bQBWzkPM*%2_9- zy$uOZs+EN(f4U9q$BtZ+thk)<+}`qdO#k7o=fV4h`W64+P5W4bu&VLvl4ou6hI7+! zGD1KF^8L@5<$61ozb0ycKp^eDra39cg#im2A`do6e_4G2hkk!u4to;KX={n?w^$}?!9Pn$%9 zjdXXD8advWFD9{l?+;rU2Xtj;32^Dc{#Otw=ri+6#?=ETC<&Z)!@Cj^@b2L&u_ZnYMzm&V1*%p<+ zN&e(`%J5MgHYI4ac@?H41?SJhuY41*eZBOAF*p(jfH9MhCJ5@2_fs0uMcX9)7W{2{ zgLrsD=AD1Mb_ByUlG5Yah-JgJA%eeOc>S#m)szz2Ea_+e?kZ>(`rx}%*=CL|;@zFk zWNH0m*rTGFu!dn1AuMtbs2WMU-!4)iopW935aI%>C(Wv+`pXPr8?>JiUiwOQYzfdN zJe-85>R0^ny3j|RCA|$Wv{g_A0j>b$j<1C1y@%U?oS#&-nKx0VM)LAXiI?@T-Q(RO)+v1xxR*`5gz=Eg0e_k{c1FR$$zaD?rWHq3ZX(je)fp!jP7XlE zzQPapfB1y>GUZ%D0{#+zV8PzNg}- zfhecy@0Zyx)NK-(~oj?xyem#kO+0(?K3g}_rr55Y>XQc z_jQYTyW!;V16f_p#?J?_v8)QN4NtbC5_4li^=3c*y*Zt&`g7xFZ$iX#Oq+nKO;KO* z$NBlJr&Cm7DZDz1hir!7lWgkC;ExwwUwF> z+b3`tTu7MxOA)sK-%}8jk#5`~#;Qu`{pI(UALp_E>vbYt0WASO1mbhJDB%K-{JGX7 z{KFuWM%OU2H`P|h#Ia#UKAlMLC63JXlDFAx<94TXU7VEZcgP?l?)QzOybbMsgbK)k zRqfN{OFe_kZKtd2&pC}obNYZ!cyU;h_kEYe!faEelP0NVuN0-uX z)*@L5SLVoo#NKqCb{tlZeUoLeP8YxJtZnEM2(=i4kYQRFFJ8HZOe>rV$y3@x2y@F< z3<~|qkV%&EZ+nM*`}1Itr5AM(%f1Q;i*j@sF@mu21SX@J;FO+u8pi0QK-vGQLa-Nw z*&QuDZgx0ddD!}}+0dVTAzGY-A2+T|e;GR+^eVzSMI=$dfLhP+$IH4h@8@k5Wjv$; z01zDE-q+RHg1DRPKs-JFBhw3#C8g64UoX5qOghohW$XG2+`nu%f4&?DIv@Je%Hwjc zBtjNX*Eaf~DyHU~h`R%EjEm2z`Q@F*cXIVx&a;Thr&)0WeaHUdt?n!xAp)<7`zO50 zEzqM4^^B866&?bLCFW19H^=_g57|mSx`ISO-t<1WnYnOUZZMno&PKn-F+oyj9&ah1 zlL1vDhqlt#n;;uzMqqNYQmOdO2~zE8{*R7}H+hZiJZ>9WV>ITm+bG*ZQx zw%zIeYA3fQQRrkxSfRFqHmzr#ewmZOjDZ2=jD1mLJ_HUN_DJmd9bS*MJZcV?SZ9KM1kXlt3+_L-$kKux`FK{-?s9PIU|cROQF2P$lbWWhx-r4*ApZBI1rcK`s%tFRgE4joUk zxfcbV?eSk(9~a6fywsQH8q3xVr-E72**Z75p{h=Eo2)B8xc`Gg(YBc@k6*6XB~nzt zgHK_st}>B$=-mMiNq-3RQz|%&Od6G^$ikrjC64*^#~Z%pPP?8J-N54^n93a-f zPht-bSMi6d{Pp;_mM^kgsHHXrkDsgSb+)J{Vn=P`7a#@dHcnxDT&K$7b<)lE6ZNC3 zKObl=G_KsmgJ9&C&P}h{8WfB<_42r6et|w9zK7@h@tO+n>*XPpmyBGW@H9h6T?`bs zm~6$nc`0ZceReeXF+<6=i)WBx57ZZeV1d)oEnT=H>=kHy`OgBPmk6{8k8&)m$3NvK zR7sJh1d1Lh82_xDHqrZy8UVwPr~AH#X5*dPn3Ra{`?NhwsX-KkM7~)^MV}Jw%=$KE zn8KEzS>k88<0~%a`uL{U;pTvRA>q!r7kM8yhl29GL6uhwXN7GKf}|I;R`RioD&@E8 zHQB@NDKEd>FL-ZnE#!=M?uj4O%W+`#>+0BBt2wDnVk!~=V-(lc#0-_;6a=K+Oz-DO z>JI%7k}c~)O}u`T)Lx8^WtIA7udLN3ML&@;6T(_Rr5@jvjlka~8vj>1=2avDrg!Tabw5`>jVsz`4!uAP-T8Gp>$fA|XHl2Ue!x$RZ9qY=z zAFc!8XCQmHDx09^JPVw-7U)aqA#K}`DDwQLafB|J2t@Fd+qPjE9c#w-z$f2kSd{Fg zBB|cH!Fk*Iy)|aN%;qlwEK}vj6<`<&r7@4fekhcw2icGi=J$UmwCv1WO%ePRDvw8M zI(uGH_vEALd>E7+z)`kD1E%B}K!8FwEWthIvAH7?Ml#>yd9{+XV>x=@K+Ejkul!o< zsN``bV(>Y_Nxy})Xf-8qabf-C#yWHCp8r>vsrdsyq+>h9C>{cj) zlLQ7)>@^7z%PTL4jtJjgos&s<5~ywc-MrNDrjhcysjxj2vxU2Clu?D#v5>)UF|hH7 z{JHP<7hCo|RHD0uo)M!)Zp`n+v*d>Jy1R+>3bq0?;o9KM0Ta+02@nnkL9Y-2Ysf{V zyT-bfhCO=hb9Xny5btwO{zN~y$*k&+Qat_dzC5Z-#25fgKT!diy>&UFVM+DIF{plP3MU_PDFqzQzNrIIWB|7+hQUS+JN#-#AgQPlvmkAUmme zz1V&1y!U~!nB}n&{*tI!g2NWH0Y%7KqvMF@h;PmNDoo|IerSh>nRw2^?zE@VNF~S_ z?wgT}U>^0pkLSJ)k~GEjcOM~iL2(K`14{(bjUo>-L;@onyE=0Y-4-w(`dMa-gL>6z z2SEc$RJ$z!vmQ?K-hDW50L0Pi!Ip5}n;xtmWD^vF&e-Ccc6NV#K}C{bG^qLI?LUpV zcje*FC%Ap?Bt7=kAMhM2cvCo4QsVhYxMSZ&WAJB#_@3~T7jwte;~Jt|<4{5W?RkU) zD1Q7Q)cxI_PO>lw5YMA4qyNgfT-Sh&mQdayz-^^-^E6}JZuz3am6+vzoAf=Rq1(aV z9;PwJp|_J?UDRMmr(sg!1P5eSIxjS)#5DF=>zc9rGqbvMwFF;8S8bxY?0i4oxJtNn z#|dHsSVt;E9K1%@3w165%V&!%e?D{`c3gZ*G6G19(1DFd-vL#9O46s#v}m#1wkH{2 z<~4Q)9eX#OhmV)N79@^9x-~NPq6T40`i&fFMQXWjxuDXb6JoR0PYC`w;gWNS|IvyE zO99by;}B{f3tSO3wy%T;8PnJcTW<@F;Sw*_DL(OGH z=$qY88o|h>v!@8XLUE~;jd*l>)Yc#4ZP*h4%lbUZWqVa)nnBF>?Cu3rOf?t_iam*c zvG#aY)XeW-u6Xasp*Y^(emo>jY+fx#RINxOvtBS)!rFl=Xf#5FT-oxi4ujKRdRynA zR>hO7OOM1(?00Y&+|ja6%u$-s!|=aGlI9nBMc}6bd@2tpcT@iY%HS zSMxD5@sH?T{HN`G2i@c2g5MS%5VKlxf!$Zcz6Dq*OlH*24GhrUhBI20FzW2^rx*if z+jGT#KDbA|?8}23ko|(SpvEMXdidv2RA0N5!S`FM-61UCP)WtXV>)NprG{4gJTmb1 z*1$O)6mKULA}I(GFBGMt*XExNa=hMJ_&BaH<^ABIBfggdT8ta3V^Fc@q|^SaP;G*u z6#NUuZOQf-1c3!l)K_kY1H!a`D|_CqnY-kL#f4Q9S%nS>dZ2bSW~XG?x(*}p=CQW- zComUKaB*Y=lyi z%rBTiiFt?(mdZrAreus9az|dum-M1Hd#`o91~GqA<>%051x8x(?l)eZd~RwGZG>}y zqI*2ltBgS3Mv<6lE2fxjqg?7@Z2kj>S<%LZk79lF z(l_Ye0dLbIFO~oHi8KigwW$85w@9zGS10E+JK*wgyTS3P_adDh#JUh98WeMIxBc4r z#9V*_Qb1z*Vmonb^f~PXEDL(~c0^*o`|W3i)zjtUDL!PsA}?y#T8-Wr3dZ&Y*r_#W zy(x7GbNM`Puf;{enD8u}x|@I$=zYBpAQKs2VoRkij>RvEiMB7-Uq6&S^6HRQhcuTI zh-PBN-1*=@j7#Gnpyi8g_b%x$y}k~ug1(>WRon~H`YB;ik2;K)O}^MO2|+eXM%KQ1 zDj3PLg5rG-TZ?yzl@O8xh79qKt%C?dVQ@<8BvA@}Z-asTz4^`0TC zkK9|xE^=&`+_KxoK1wQMpVHDB>m~!yt_zfca;q>!Im$hC47MCfn&>7UYP3INGTs^NcJctE3P7r^rwF2 z7-<4$X85AA)3G-m`-I`H40j11rbq#k=fMBWjRqs>x@E)Vy1hF%#i8Vbz?clMYV-sN ztnA3qi$#fJ?3m4Z$2w-Nm{}S!Q{w~L+D5g8bk-`y%yh|EMNBl{2~q)xPsFRVW80K< zvVmt7(uYhED_YW^c>jX!OZ@cUJ2;h9JFkNDGf-RZqBCGD1z)|k;%7QaU@$J35Z=HOYp@P9~3853$1cXGBx|Qg?k`%rFl+WRVYKW`wubk&w zZ8*q~7!Hs;RW)hpbSn>DU0Erg&iH4nb~Gd&aJM&^y{HJKY>wMmRk=%g)_`)~7zA|@ z%jg<^o+#6Z$9TMsk?VL}yqh`mWc-_6C;jP3t+s(vbnSGb>{H-OXSA`Z2$yOY{xn|1 zUm0v`-n>2WcDCt3Q^na+49#&iSw{oo%v!#BUS(tW)1AWaYbI~wh7wq)013C-=W~9< z*JBHBze$@uKG7r&D~0k}2IReHaQL;Apsq~va zPKfQXl1=Y>?f>G$bI>59rars%?CTuP~?-9RJr-{ z#jE}I><%V4efL43wndFvzIDR36!^fCSlSg?eEVYO_DA{2{O*eY%&`{{cdKzq8-_|AJjl6w=703J?Uep4 zEX*tkq(Bq+lN2R<3YH_H&b#VS+mWbWYO4G*S1c`-=TvmsooL@Nv*KK0VR1I%u9GUd zYf_rbiY@ZtoC^@eAyiVS?n!`C%eEVSfi_Cjn=LbFIwMx5Xl`K-K9lQHNFAO{C2^L^ zW`e(iU#XkGLmcs#p{5@aD%yh>eX$UI4CoV-vorI*w{*GuXI>xXs)Ey>vY_9Ou#y02 zbBeNu%yBu@3(jyk>Nv*qX3>knvqiF9o^Q9B`}5-_k29Z=(^F2$6<}yfO6h=T^}}OC z6^zkWHSG8>(VI)*pFPbPyW5>^(AYEM?;%tzuoA>bSAFkz-6QrHa$CxE8Gb_0wX23+ ztzN7k|NSlhpSO;yP)SL#wj|VXLy)AOgE}{Kc@%1j+RBxLPx+X z)-0%vg8@tr^mFr(^y7;mmrI8}XsyvpNI4P{Br$b!j0tNTWw}!MPcv*<{yt7%AS7E77g^Gidr}6Jv z-8oi0J{f3G_ezHkRj@Zby*s}kXIgdeeZ*S`Ps1n_dq7aA6wgt^EK{SRT$fX{6btW+ zz=>UByRFeXV9#u1AH#H#Zqs%h(Yof@D0ry4^lEU`IvF|e2ORh$poCsx1(Xa6T((&u z;mKc^evqg`ZTEx82lfXJ;OgWtehsRppKElw@I!TOyK}$8i^-*!7UZu=7NjgA7bzg+ z;KO*VlzF-->1u_$pYDg2o6j{y0@xk)!0_N+xS6a$u3%I$mPjydd}XDOD#Yv$cN#R; zKq0sN7Oh)g2mHgy4BS>EpV9HWe5slX!BCC|C=zs4slt9u`|6eTl9KjMf1vTjHe(MM zU%G)__e6^kEH;pUMGn94rzj=>B1l;Q)nC1oTF+10r!qgpXv!{0!RPx{c}m=O{I&zw z^RN$sk`VExiOB6{gpngiO7`|E-KX4>>QLz=r{E=0yX1Yk_;u`*$V-jM`xBD1J0fW0 zOX(oKt`F2#uEy4J&*fjC{A{p$Fqgf}x};k#LR_AYg6xJKnP?bt7l=B9aJ_NI)N$Rn zHRVZY;esbJ$l~_Cmt!5OfwVKYhO#Nci>ii>7jU0qA zJX8&NWP@*wUl1!Jngoi?zVrm2vSO3-v#-4%T*-3p@Lp1?vxLGiVl72XH0JDW35YPe zL$74oO;hj@=+CNfRA5GH8RqNZSU!|+1N`F-Vi{n!FH5Qw1zYJ)MqgvD< zN51=si&aY>@9y7JLJk1a{0$*u-`ghl=`#oiMf+eU`h~Y}?WmRQ%I> zW`M+AwqS_5B9^IwlM;}B=8U#SU51PC+a3Lj!QV4_Q7^xeT^GX4$vk;T4$YU1f*f#+ zS!*~vYM67b<^e@8A#hwDkF3H<^jyaT2ig}xEf#%Jqz-?ph<%_gy{egow#}o7jnV^*c z4e8D+B7axEx9w+UQK5<)tHGQi9N-!%hBNp-?AmO^l8PmPiZMVkK|S|++S%@R>qIMj znj}|x_sqi71j|_0QU#hD3C5#0J<4{ka9?`+&JcKmVgeTp+E>JWpMyGl$-~O+!G>?u zO*vAmJfDzh2A>ZpHTMvAD7nEuE^YopDofM+y2#A@RW5|6C$FF@l2(al%{TiFm=rl# zsT+LKPzb}+-DL{}H1M~{#A4k8#utJ-U{6Es=gA(%jbFJ4kZvJ?{O{?CBJ!#F4(d84 zdXzQ<9GHsPhh{50{>(h=v`fsG`JOcAfW+^vH2CZeEyz+NnrDLOI6c67`Yd9!wrfVgbty*^YA)XKMov zjIvg5+H}UXS}QB~@N&Tuz$YeA#@NYq%5+`;g&ZBVdaLJru9G|RTds@w^08WWGI@(lmrl(j&B?hdD&T|>qQaNJ*qi06POTKhR_M4N%4(45vgxpE>)sdbvsheD$3NFJu?nGt`mO9t z&xcJkRXoXVap-9QWyx8JOtVc>q!y{hUZteoDm*i%q{Nt_?+)=-#dlp5W*Bh24RTtVV<-?eT5z#Z=dXhEh z#i4fcC%-%XEJ(L7TGpW9ld7!Mf_Vj|!=TLkIb)$2ur1+u7qcjehi@)@M z$g&WhAmpzoy?|OXWtR`2G?Yu9f#4jGX3_tSVPRTa8diy zICmuev7pgh5ZjhuG#ws*YxUbqUYfjO$fziWc@hb^$DVG^EV|R{&!$Wr11`=X3^m|p z;(_bD3rju@bkPFPXYGI?M(dB#k9Vb;>OcaNL-nyDJ zba`T)y96w8pPi(5_2$6mGX0{0XB0#&&ib5~sQW4Q_T%NoJa777B*8p(u#TmAD8Q+! zlELo8gCBE$yLS2#q?RS&3V8Ltmk_|F{K!pBmZwvgGO3yubyKMN2j%fjdZadG;eH(S z52hdJa-X`Zh>NmyR85V{#pGjL2eNVAC4ZlzE{bJVK=##0K;5%1mfE0M)oC0kVo5?! z@k<7R{ovi8$n;R9hRIfrQLpmfQ86;qF!)$s+1IIz`LP>xr+~p9Uns4V@SZaW-h!Ku zLOgJA0(4Ry{(fs=^T@mNc!urBDiZYyA;`FhDBi!NRHr}O`ry`f9J3s8d#N8y%c#g^ zmf^{&PE=4GNp-x2maX@)_A($L@;68J*ovxolZCOmib<1eEbUMd9OTBDXB5FJ_)s9V z?q9}HY+oWi0e2xte`p-5K5avB-ko$10;;08d={y$3YW*|88BoG)b|)t@&j)*{DXth zAZz!j^bre&KtQsRkbL4o7%`yz`xDz8qk^hKxl>-OfjkaO=V=^GR?&t0LDCUd*%Sh6ZB{ z^n|UnZ$n>%FrD-1pMcTnLC#nkpdq(60f1xBO8{?zX_?gB9jl-4V%^73sQ*W9@+a)9 zFKQk&Iq0qJKiT4fClKdCDT2ZJs*X+#oXLXZ0dLn^Xuy{M+Nbp~gpLpi>UC@9OX8)9 zAqe&%A4rq~amW`W*BCx4;!+c!wRZ!MjY-A00o!srVP>zURpNbo)^$%%Q@>G79PQ1I z@)Ozu+McUOR^El)eC^AA7i5DT?cp$sl8d>#>*5S7?v5X*yf?1;<#_nK{aVLOk9?<> zf?81wV9!INyu6E^i0&Z%ox9QouFrfi2lX#hfje<*qej#~S|c+C>pt7%O31}Nwy>oN z7aWeqxyxj)jg>5G7^$kw`!e(RS8w#YJ(nN312J<$pbW@A4&Q;XT{=&&c{&DJK66ow z;S7g${4}c*3AHQ|Y9yO3u`B=c;IjFRXW?Z|kIzqlT36B0me+(Zsu*(|+!@hnci4`B zy#Q(M`MWpwM@_1w{Il2N-A7;6jy(vSB~f}dXjscok1Kbn5gOj*M0>RwXY#xSzYR`9 zd-|QQ8~+KertEkPfYU3y%Ma+Q(v-&-a7_ZFAb_!eMQKe2SSfwI<$o0P*4Zu*NR%&B zreO{#e8Z|iGWs#i_zr#Q{I9?^yX7o6sjc@Z)h^f;68_FP)f!twU^^poWv4?;xFiZQy)uAZU$_kvxI77H;jZV@BLCR`&=es`N*!fKhI3WQ z&g&Sk6FtB>9w#!|9?M+x;#&H6Cp|19OkxtDr^}EnO7&%7&mZ2e2IFLnEsRWN)(Dk{ zaHy<>h03i!u?hquAfxatIjn^9TLfDy;|TCpJgzI`OLMWrurU%k2(T&vQq2#VL*E+YBX_wJqNS^Pg{Uq+D@Gsg;YUWFu7* zW>h)lhGDi7`wvg z|C%NcuZ5jvEA8K+Br%Lsa?`kasC?jLA<%wlE2nKAYevmMVShd zduARg-M^xx*IwSFU6SY5!7cjR z>diMskQjyz^pv4ys6Wm(_cuXSnNhUap?ES(96`-}6Z%#-d{MDi8#1h5{4fb029<7D zbbuOXjGw_7ou;{E^Mb;xcO-FIp1XTO+{keJTwP@h9F=c+80o*T|MGFghGSkMoEgp_7`leBetc5z zx}Fk}yFiJBK8W)lx!9u+QLM1u(oE=w;ah64i?ZOSgrrd%ukHUTavVx1?oci+ec*;Xy`$EbI)hkUhtRMtqD-SwZLWU-f z?k7Cpmpg@BfpZ4MnYTQgUSn$^NL0=>6MOT7CT$8uxsufwt0K=NERKMyzTLrlgE+2$A z#ca9yv>|xX?cE#db{nXVtURP|Z>2l@sU(?xTm{~E#w2%AEqAmyYFC51@X8Y3^7q3? z9Gn7-gzg^fL)oJ&`D-eOLXoy3dQLefE11?(Vn7H$Vu@&)XyINEGXp%#%rZa2pjl*x z*q2(jhTtM?GOm1k2z^+%4F+uyc!fxs&ZR5kw&I1W3!;IPca#qh)u* zOv*=67~@Quo5rE+gcBm}m###}XCkI8d7&KMg3_1OdBlNOd#K3YsBGGm`Ny>xg10W_ zBRNIal0xrH%;qk?k0&=UGJBKxwhP81LUWK}XnFLt6fBJt{)Uc59y8S1dI^(JT;8>l zbzT~Sm6bWAk&6P|-Q_ExC5x-7ch+<)2}l-%3Nl0PsONm2UU=lpnVAFpfE`fA!Go&= z1K&lSJisP$>o^{Uq<1JmL%bgo{U`ks*}Og;!0l0Wf?CyqxrRNMxl$&sqOC13WMAJ6 zEJ?@JN=la9ThObg!Po4I%9J^?@c>cLBg|k+OWb!L`p`Q9s3aMF8VS+aszfC5$VbV+ zpQ-aZkpl)H2hT}HlrHa;KEoM3M8}R9jq@%^-mV8G#xASU_7C{{nf=Im*}do^Cxo-A zBW>BDje(~_V2Sf_>YgO&Ju-^VOqJbpOy`I9ftOnuzy$>N(E~>Q@k`iLtdU6NP!=wX zBDjM-UP7W?)VGBx_PZPxiMN|9770qP)i;zDI^N3t3#C^s;6(Q|@bM>e526bmSbH&y zOXx=`i$n~AiG_0MgJb@*PA8(Xxvli?*>B@?&^L_QJ7atykV?c41xdn{iD}ZY%H5b5 z+Y2sL?%aHgNOlsijvg@c2X2YlT)OZvb?O=JB&c2{IP^s*d=(QkvYB&W7GY$K47DPP z*GI~~^ur6*B{yr0B$%A|n1(%_G!;l1h7`v*c=}|3Y0wgrpb(1)X^8HuH3~7574!10 z=wW^V8~;4`a#^eT`>q#+0%{_d4>!hNnZL&=1c}DK*Un`PdP<*0pV38fJ1(p>T1G(q zL(=k%D!=bb5^!Ptj0qVD2udvdu3(lLUk!eQW91+U<2F#tXJoBEq`#ug6xhMEh4YJmJ(U(iTK&w zd`rz4<;cPb6Rp`1=rDST!xM!4Gw$felD0RTy*>Z&w3go+1TXh*Xq-yr;Sw&tyP=dUDA$eZO$(cxM35V0q(k)%sRBHTqmfe?LdcPe z$$>Zz(|8(9{cXR)Cl&LDj{um3IUi&j8X8GP?9;mN^If;}K<$YA+P^`@IDVE$;f{>m zDXMG9vWjqJsDz7kYm`b=rggZK*CrD%$T7rHo_H%ReVT+f0fWe^YP1Ad2H&5!a*SBs zvqd|EEO^477xPS&`GcIzN8(6iHj;3E1>7EdYU&PnZgI)+ZoozL;MC#%#%^Cy{H7aw ze|zOo0+q|4`654rZ_AfhKnp6(t`+J4hKiBXa|abZ;i0E|ZH}R>QkCNN?N{YfRGep`flh7x=$W++AVC)}l(?WA_}fAYe8Z%c5u7)<3Q z_Kyb&``@Pm#p|p&p=99-iu`ipula^0B2Ska*FKEi1wo;>DY6gP{<^+|NO}M)H~hGS zW^@FIk$Jy__eXs`I|dNVYvonD+w|F5vA3}Kq&D8AEV5h96u30oIO>jZ3Yq_0v;aB3 zzo9e;IjUfO`KBDVPAPb6K(qVOJrYjFp%7wzj7iUUNK2&d6!Fs(x*A>a(%B408C-x1 ze{EwLyI#DuK3;8dN|}USWpA%j*DsdklZ`E2d14`9bvoy_kp*EzMcS`WWX!SQ7XUz= zjg-^fV0hO;Rd@5t>B%#Y8Lb^N6I+lIYcI#6s5kOVB$(vl1m@Pg04yB?a3Pm)N8Ss~ zcS}%E&)N{$mipUxg^oCF9q=A^{z%St4oRr&y3oXA7K14srJKQ*)B^W-X}bEZ38jYX z4xh|`7S^v3Mt&1^=H1rPUYLpADQW!9<}D%@%FW%deHIK30z)8#fCCz2SW3aH_*LkG z?eS?%LN;8&QMZ3LjELZBsAVAGEAO>mIy`)!{e1fQFZNuc_VXpur?0H;)TP| zD-fx^IeN@~leumda~YD;)xFrOVR;}w`5@CQR9Q7o7wUH!nnY}5iBmuU2oP1&Wk^)< zl;V-ia0ueDZlUB8USrk}2bo5F}`LFOA7s6UvK{&ExrW>-@v{@9o#nkB+6Y`B_N(9_~>p+=TkB--5Ykx03WUL54rUcpif3`1r+jZYy&@1wlP=Sniz`ka zf8uob?sJ;KluAcx^SWn-AWq~u0ZW!FXgHZQkEe^vaSGCz?Z0kzPJC-K$=~95i7j)( zlTlmy@RR!$Kc6c2ivt6PYly#F&p?W;Tb$!E*m3dBIJ>%bORAoW(ZdaDxYI(a6Bg6W zOfP%HbP1!MI$td-P`69u&J;#6x-q1O#uQMN;#lzD)ARPYia?4t?7*)oG&2K^aQpcD zyZL$k1r5jSDmWXb`6o@27Xxf;&sMRmALhkJl5?t7&t0i-5zw;=93`JxI<28+ z^gwE%bJgk>RSt?&&MUAkM&pT!>_#+>8qWSxKSr#C`wbz6Y+s#(e-1~0f*^B-_SDdM z`iGEi5CkGz4Xkcpt7FK^7oxBJHW@_n-7^b7ZcUTjFK|1F!QW1p|0_gp@1-vv&|vY< zbF-s9l=EP$L*ZBWt0R zcv!{-a)YtGuvy?}y+d@R?)uI<{&*yZv)OQCo#)(w`EG-yS%>wPslR!uc&o$L%C+xD zA6G_sK@&CNU0B0afB9I;kU==Zr5Id2(cW)v(~qR%OIvDgs$8Kv^n>z|N!Tj}MWcP! z8Z*qyy}c?f95X=aoh01LFreX11o?0*`-Z9 zgx<|IwB~p7U0WRg1?du5-5*gs^Y+a~ZpYXTHYbwsLOlL>Sx3xnTfW}9@v8UW%P0(g zEBS6mn%h_4V8_3h7iLlJ{s<=o<5bsm&1a@rT;~tK$_rX#Q#p}o0chn12dUK`+@`(N zuluqySdPu9cNWrH(g>rAblTJ2wT@6&kOCaa!cP?$%5h*S&&gl1S+p8Bg18lU3lbnE zB!a561iawl%m+EL!pjIqlJVszTJ9l3F6e~wvpvQ+b%B3^-nR|<@|}M;E@Q{yGlsR+ z+DMo<_3^5B{nnr>%%@o*@M9TTw)a!vDZMtVfMy~_xD#s_gRmgYj*7?fIhFq1!3 znPl;eTgK5~shvo{BTWte3^TLh7`tz3#mo_@*teqb@4|&vQpw9UrVhOYzW6OP(%1#q zn)by{(DLEfjlBBn-@fE^l*e~kvHZqN2_+}E@I!Z7&K;>xLo43MKxyL_EPufUmQ`O0 zTaFhQAy~)ZksJ)-3#4II#(>DQUqkK~NIk!?$^1(JAPmCTO+lB#-=N#1e}xYaXSqJar4Z%(3=+$r!yrN$^#RHmRqH3xD%cv!0u z3Ikzd9E>=-w*A>roB(`&rbdpaWp$VP@R;d1SjdHM#bvp9ELv<_?Vq^$K#tvEPg~1_ zrH9o^sIun4d;U|xVDVWjQX0+X9wuPhxD|;0A}De6ZB2DX8>sQ-LsDhL#_-$KGcsTS zxEKm>DSjw{J=(Q7-!mej!1I<%(^zII)5NN zj=;)-*EGJakNCHR2$8Pzyx9ZH{BPc1KXu#0NiBK>lqu;g}s2= zS4xaj!TUd%FK%5UJCrM~Y!T=F?q- zSPuJx1ca2Si?9{5Yw=$Q;VUK~GkyTf2{F}u2t7TNVTp9ruYcRYaU@%}3;1q*^whj^ z+KqB!Wp$98{GD%V2+7aSeJQnw1e$xqlAv7kS{-w53d|dff|)voJqKV&?r`MZ z`@$#T82p*)!d+Sllv-}`%(NDzteR@n~`#)g3D>Uo#bAn?o#G| zr1TObO1`o8CDiylOyr7S<@bCuxm4ow&UI;w{-jxl|1I(3*-@P%B}uBZ6_6<`)=IY2 zRr0vxR<}~fwndltiyB*g`Lso9i;~BEm=Tu0kJsAdg#MPq@Q;!7b?-Ow+!a<_t{A^8 zV(<+;bv9Jr;P4N13i+`_Sh!t3DA9Zc(07F;kp<(xl@-!seo<>l{Mxj!OlkE0qi}?q zcS67X`^Imy$v7MMMlU=mq*I3_G3tv-PUd9qny;ZomV;MT`TC6dwfY_zdt56RZj?l_ z-*G3T%eMMs}_c+)r%pMu>hdG|K+=ajF5h0AMx zURjKRQl+D}4W+3*rOkHe8hwbFeCptzwW*tC5}2N=o%V^-6|>vu6QoEI>4NV!{t#HF z6V(8))EbU0aODgHG$D#)NWr$=T?-m%6rq{ZEs@oUw4escFI8ZvVa{mpx}Y`qPCovf zDx-B47QtYWPn#wtz=w}qP3`~TuRnuC)z;sCrFm=iGK;=tqb%;SUVp~?5>jdWd_{d1 zXIm|iZz;9q166b=jA_vSwO0=qev3stR%qFnE*4sU-%nKvt(UEUlMD_T#uJlR^lf;U zfn=p#{TUHktZ(@^{?B=3?72srdObos3CZ66KbFoquIaGr!y9ZcdUS{|x{&MXtP=2Ef%%9 zQvba2lZsg&Eey^)L8O+R<_af%j(jkJjWEb;tLhG(Xr;6>l7K}M($gwz@(FLE`eJY# zoSD@&Jbs+A(US1}7-2C(f^^}54(pV>xX5#g5N;OnZEuYaKovhgb+ zPZc=A6~#{gg5kFcjUZ%9Sz{arlBlQ0iT~hTzq-E`|8o7cZZA@h95D99<^J_<5kSGc zmF8}IV!KzF`=))5_Hu4LePWWbdXI4?aqieE&_r2Lf~e~uIko&}|Ix7RutnSRAr2s= z1G5TAngC|ZA6(euV;tRUV#mbDBGr7Z-1Ve_0bdWnR1KI9>nNzofd(lP>+AEpyY9Yy z18?=HFdr=5b89UA_CG6j*e2r-8B)^F);o`K*%kXfB=a&Ke#~@m6~co|mSDze_!blW!*U4NfQ_=OR-2V>Bb z02`hItR#fyJ%vNV9 z0pqm&+Eat!lVogQ-vi6CUai&?IRrAGqO@68IDvvWMcfsS(zoJob2CX+*$9E-(nQrI z>Er+!N=zU*%Pui7$OKwRjpv)pe5@n>y3H`3)n#xMECY(tcrU_QqY-KDcI>c+i`nxD z38YdO=eE|c!u@$iU*0k`-~;kDoPKy{=bt6$&wdR{8jC;iKxCbc5Bx9o+wzFn@)bL; zBEf`@L4wJJ#jG)L#h3pDS%=zHx_Hs&DvO^66*zn`Ho8fVs{H9CCSex7>J!-jIPLh) zzx26*xtz|LpLhE@!0J{iit2_-?+yE44zdsnU?!fJOcjhPfez~nnrK6Ssp$DiepODMn(4Q+_`(ARV9-GU_ z=QYLuCV!9DZ@P|rFiW$Ew>nQ!^)8)FhSiDq*&zGXi*$_Pj^7}@9qi9LJBOJi3ub(+ zyMq>AR>$A~a92&RX!+82<7R7mf#bi|6P-S3! zdA6QGiH_OHC_kpRg@1%-sX5DtbaQ8D_$mNCx6ySmcqq~fRA9RxX}WwsP%!ilz$*#g ze@(2ljoQG0O+1crFc#>Bk2+&`gCc}llpRYK^=8~Y;3^PYAXDsy-vctSCaF-Af>Dy; z8SkWMdO0Myaj>|d>j9XQYeTK4*#HH*_X31y5H$802tjIOf(2pMpY?NF<-dQz${FU5 zc+$t;mr{8AJm;vBq+W1_PwK}e$`|k3dhoiU3aABb?xV;bbWp|n%nT%>RS^#S*K4Xr z?~Q~6!wjJKe}!4SC2?a$_%B+sV*0pV{HM@HvmvOtr090!`-4VPl+zmPUvCD0%)8cl zbv>$@exwbDI~2uI%7ck3iDuZ7@;210e1xDP2-gjC(k{+_Z}-)rcVN( z(Em0&UlFjy@b5^H){$rt4PGPcl0NProW;C?5sFX%2>nTDgPDe@Bm*r09fKA&+E0}1 z8Zl7au{gRPKSWmD+rL?M7@A8Y0ebGAcP5?pB9?P=Gw`Fpnn`$(KQ#}QRaoK{QL50UbRx92oNj;Z(w7{M5hK9G4IZy8LXHm zKEGaP3LeF95xvsw2g>?FVP$!zc#|lppX@YA_yfcmbcvu)e6SE8!>q6wjO0^}zk-jH?C6L-O)hU|-_{dBR@iO;7)Ph-$y zaHX8CvR0p2-Zuo3akot5nV)=3iFu-cf=G%#jMRt}1tx}UDR{23_2>zY0hu5_wvO6^ zA~66`M4|AHO!=6^w}SE?%Q>T%1NNen1@aZ7Iau1UL#zUG8cJ(Ov?EDYfsF%;^Oq+% zW@^hjf%4%2ZVFE_NaN_qt5oi`f>GC_m`*eJ4_*WopBjjAeYn~2;to|1&yH#p(AWZP z*arsEBYjPZF_S8jO|Vi9pCFHU0{T{ z@`tc5ZDK)mvsrGdA`}iVx>27xf0HNlzb;@+@Dm1sR_Fwgo(Z)9*sVLhk1d&rH(?ZR*d7c*-|@y78ge#|S?1al~xC|YXb zhOifB6*E_+pXL$M1%w&ANhoIQEbxy-tk0U6o^-T39n;bVE#Haz^Ii+rvDT4ZV|~M1 z1UmQeIPlp3JPgsVOR|0yBcF*fs=dWC%9N4cHcW{JI!NQOOGAzY@JNrwzZvQ6(_7?k zN}S*IexW~52Xys#0}+|u!ALMCoLqrLJ)i+m%={5ubyF&RkbP?zc9PE$5J#b`*^_L2 zGi)jBFJQ~rLV`_BZR#qcj{Lw3qQ8ZHB^0|8zZSpdtxND6$m$-I+ClGA(9L1?rc7hH zA_>ElO2YHi3w@QPwLgDN=*%7^UcNh!bb0c7TJ=r3u&W zH~TaF3GnQAho{6ROlakmBy;D75mGULmFrvH4+BoeBi%;skDq5TN~)d9bzIZNZmWdtp}U_c8hcTyjFLs=gK%L@8KaY zo`nTpucCKymoL(+8(T!tn~)7P7l&Pk%#YlQfEQFctEzR0qtc_uwBU?u09A};)G<=) z$uo`L2|)di#QpKV_}c<}7_z_Vz#ya&p9+uU%cYao2yuKY7Qx5MFdiPEre954bZZj0 z>Y>R*|9_(*i;yo9h0DOY49J*d$dDulnTw_)GVd_r3O z;70lH&tLo>rv%?={GMY@mQevF9OomQ=DLCd&}}V6AzT~DsZLqQIZnPyP@Gny)-bER znZKN%_Nviiys>m{5}`d$HI9q+pHm1cIFWQ^1L``>0wuScK2RA>Vr;h2&IXKLeR~;D zj*Yr#M%^x_5f-3hA4`&p&+5ednSun)DcUg7+vVRRsn%rxXvZ?w#*du41Pz?Z?Kx0C z--5lL+O_%NIlQSe^D+L={SdjVubj_^1~1zCaC73loGxV9rTG$2jL|IFJn_|hKbFJw zB7YgZ4y}e>-g8Aq>`d|Uge>1kuXB?~&^-SX?c@FY)@G<^pD_v{Jt6hiNVofW$UZU{ zZ~b=k%bFW*kg?lJ?IUi&FN7JIEU_+%7cRS!<^A zn~{a)WRU_dBl%I4L{{2GsDh2uI7SJR_raSr__iJcF0-W`^h`1u#=YwIMT}Oh$zywnbu!hfb%l;_;jrmo zUV3G^Wg+Jk=oeDQ0vYX}EZ=T2@HMMUM)}0~rlm^6*q`njse>G5UE4$Cfp*87TBb2mSYuIRQqzC<Z$CX0!Y30VxMGNQP4^Jb?|9OYM(~$m?ZAeBU ziIFEiJECwb%c*FuK7;;18C9OV{kF|>;$rsc8N!X*Xo^Ux(##X>`1gsL2^KBBqN&*~RBeN5@seMEYcv3qhoT0R7k)iRG<+qPYpu-W z*2F0W=wL|Yt@*!LL1`z}kD z`axgOwbZAz%#PrPkps7%@F#8r%WK=Uff7-={WU|hLe#T$>8SSLX0$d4ew4|5#i<#? zkSViBBLa!)+lwyc&Dp$ES#!Ol!}ZmT0@2M9F-@yO%fGI>0VgHhn@qnGS+o-R;eFVK zzW#w4z+8G*+#fmF<^h~8rER#*{k?l(LPx(ILq9kIQWvj#M2Hyr|8$5eqNZ7L>UxwJ zOf6_94c*+%?cP2&JNwkc8){R|!r7_Y8* zrF~fCDcr5BAvTkGFyVBuseDnJF}PQ^;$hkL-u*dIntzc70@T3ACZ-307+ zcl2ck;b>>vGz%Ge$E{6ts;2v~(A%@*@hGvOu%@@OOq9MM|Mbn;N7AoPe*nv)U*R-& zVM(|mlm1Ggtu=jhZI(vTB9DAL$iT1z6GlQDM%>ljhsQaB78s1J+vj{)4jZ8GDQCh( zHt-mL|n&@)tEg6+PuQ@5mM$M2L@ zZLeo`3+3%Ihw|ZVR1%vAY$^X1)>lf}2>I?bVL%HQ8sqzut;BAHKo==y*2M*tUPZ2I z3rlBaze`@Ee*I|sqI*EK^>xV-VVI-g#UFfNb)19_9D~)|ci^vrU7|&uU6HW!k)n?# z0H7TrURasgvD@%`MD4bs_RjOy%0g$V7=7`fu2U_xAVeqT%zAKFu}*m|3KIUd-|g94 zbYzcAr1_2aQwym6&DJspAOUd^K%S=h9Hn*C3fIn>N0zDEw@nthB3>`tI{)@q5^p_B z*WVqoYb?odz5J&rab(fxQ>+lb`#yTTyPry6mqk#8SA_l@!DIBGM7a&09*Bwz$Y25X zSJ&aj(Dj5Ky>Nc9%7-2Fx0_x4Zzj6^ z1`((N;1Bf)ms$QNS|V^Cvijl$dm2MKkjYa$8$kk-iLCy!<97Yg!oL`lSIB#EK|R7Y zPk-L(RgK~dGq7!*siswqeA|~fgN(Bhq8lYM-Y}rzA1rJ;Ll4=<@Avp#g{Aq89W5SH zg5r};)yVD&>JQFGA0wSOwSDF?m$R2+JT))7?OJTo4ZLsvUD$hjxIbtf)tKs~HXv%+ zI()|z1mojAhsr&#X!&3kH?3HeXxe_7z`}0vnY7R0#c*MVf3V=(-+r4FIH)v>Gn(uz zxtDuSzIH*ZyZ4LMCi2$D64sCH9%Hct!x-trPb9%WYBC6m+FoJo6L*Y1bVkQ(`s{dJ zPggZlC5A|ITt=ix5%-3yq3RRfpW+uJ(>~bQdX#D20#WEn#wDm-Z2T~BY^>-(^%U6MM&h;mLFaXk* z$%zIyk{9w65~Q$ciBGBVl^f`zE^D_lyyu%*-j1l{&qoRMbHXD0GZFoKzDS}~F%hD6 zX+vlf)Zd+7`jb)mY#oEi5=< zn*Vqe78yA&m*3xnm1Oydf8dQ^*`XQ28X~;FOEKrvFIh?~n3>7&@_M|nAMw6(zq|eP z;)yne1(~dXwGcNPxC#(Qo~?@c0~qkdjv;~6f4Q;KGR`^O8|+OZev7w#X4l4eMlCO; zso+14d}}CV@zmZ74q#Nr?Z>EpTUKzUYAam))Aj>JO{GrqL*v1)cAMWSu>#twfB{>h z69F6}53+KxU&ex(HTUbYxp_Da9hGy*6JMH;8hy&W%i-R_ETQ8l@kAv{guEXA-i~bo zKPekl(~tQt^RJgaL=RSLG<~g+jVhuW;Vw@s$%#skwxXN(99~u{BHv8J#!a-IQFP|z z-u3ocJ5Q7n2Z#d(R?3+! z6w(PZl}(+uso6wLUzDTfX#l`tb_>|_PNLXak&!Mc!hs^+b;X%#1Y3VYsV+q3P(r60Xif2x>5 z(pmyploWz^EwB&}kAFXBq5|VeeM$@+s-ge!#u&P7PYfWvr2N5+VI&s|zw9@~5fG(qZ8uyu?Rq0g# zt$%Xz2NLsS2$@pcWja+j$BM#HZa9Fm9RbLV2?3F1YwEnqE4FT4+FioRa6PZevW+kW z&cg^M03aCHGz1NXBGWD^?$`Oq&I#RbjKUll#e1~|Xk%ycU|RRa8qoerP3wM{cq{nj zS}pRfw^6!2tky1`G(53iV#DtXCxGN_sa&<>gMYVL!+m-799y&T5PikwE^)j@rMkUJ8w5=f<-87pJ}nh-?6lMX@Z)2( ziL_Z^6+V$l(UN`LgvH2g8c}3De=wPtCN42H5h%<;0k(`KQ|Lw4h@+vnB_*Lhh2Rtw zjKxF^Hj(|conZhUB(hlnK!`~k=Ja{V1QQwd@9!rE(Q!KmR!)bL zcd3gb`OadG%%SY!?}wERsYTasx!<%7H0+UM`HBS!r(P)h^}E+eoP}l2U|ivC9GFwm7A~fBlDGVDJ~2}6jp{wj zK(V7PW8Igdh3b%i|G<^;3f>Nzi=;iA&=Q)virhSmJtbUb}u6qLHp?-uZg9aI#z>8K0l zarMB6scpljWoTr4kiim46G6X;o(5x{z{d=QkQUHbE5Ij1{duD9R^`5dt&i~=Cw2jF z(hTygxmHynBqy9QlQWa2m{R*5Ve9SX{%~`Ia(g&JE#E&JU0!@32XhAGNXV&(r1?2v z7X1D({LSv6{-hvQLio|`v^UnCW?Od^z1H#Nkl@dF0?;3c!;(BI>uRL{28@}s13w!g z{YL1CeK95r(aWU8=WB+lAJ5Q}MPW0oRNGSFC;wS8hL@b@L*?{eSw{FXXF{r{Q^I4q z3NB#cq9#nfB7l^Ag?!?Ec3e67ds}g@{vgECLNesdN5V&ri85MIm^2cN-4{z(IIS?; zNh^lisbg$?c(WUdqhg~Or7U}#H#$Plv*6p*7`WN|IJGqs;}ag_56wdPMXg__M5|lE z!-{3}xPHstY1-w~m%t(H~89y{6s_0*Hob|*;A?b5LHd9{-9CjGkf3Vm`y4-z|2aB}p?8Ov$Ve>-rtm*{#`gWz88pn?EMD=So|3i+1< zHJI`jH5yZ0GV8FX=j(HhIjqno{->P_Z%t}pj&2`4$nAz!jkyr6G89qZ<81AL-Y(hHxd zc6w2fv;Zhikr71kUYpx=~O z7Tfs;b-MMqf~q(0wq3be`Zp`$N|+Tr3SLp#ft|Ohy206$5wc2?{mGU8B6=8jEdofV zUhcLNOI36aY-}q4Gy=v-Vd`kN2=>$v$?NwYfEN)_F}>tlgy+?pkT0 zR9Yr;(54!+^k8DjsovA`=JfWewe|5zq84>;elk%~$tr1>L^|L5`&{frskg@!>Wc5G z+`GE9$+_2fp*rG<$B8J5^e`z6giXS%U5fD=11{%3%M#-0xDXX^NYMu}8CMAWBT|{h zsoKD-XQ`zvdvT7)_%6Fb;c4yiN-NF+O^eWBAb}#MGKF)6NV1Up*#3twPo`mMLq6o^ zmTjceDS#~?7b)ngbRmM_#is#A)|CtdmrTX21Y8%oH{`XVu_%B0Fwwnv5N@WU7Up8-_ z4SG0aYoPD#b<->1f;t;HZS|^Ad&SK|;sXE$u4WxCL&jMN4C5wq^`Qj*bc@ zP8Msop){)kN|ETDf9O{z5iR^ZEHnJ)2i3ZOsjIKWcv$~3{_sFZE|vv1Gqo2sJ@Kh;Wa+R64o~r(d+LK>2aKowHJv8Oc^>@eE)jLcYb#NgD zBBWTVIpQm7q`Vw;5t)d3JQ`X-mAHB3SHhVMv4|mP5N1vMnJBHAm-mjBr@i;h=J^F$ zso`Lk;^aVZN0BA(0uWOo+L!c&F!?JI2m{+hD&JA+8$Hkc{rmt+@eZ4LbCmx&F`8Hq zh}R}n$gF)?v%BzS#kSGT>GjMA-_+qB8|!8cZH>%lnK)G##z=@P!VpgrPeOSItjo3R z2VsZcsslaRV6e{-hPvFAH@!Yx@a5hB@CsltaV?Q*vXeJ3$}w-&?>7fK;S5zF(PuD` zoFUA}QGuEZPrtx#1vWBZJ|~o+<0*Rg-x!A|`s&oM#W_%h3s8S204d8A55j646bdxs z*3S;;-9Ei@7mh_QB_IR$GsvR8%k!*_N-=>gf^jF~D;u-Dmc?7|XS@#{w_68Lx2f*) z(bay^#qbsBg+C`<23L=^E8Y+DEv;!SUP~E~cbDHBmK5))rLh-%MWSZ!&?86Hr3oNE zfuBqN#oEA`D<$bSI0cM7be?sC7NCJ?CBtO$L3`N#(rA0}?4;_hVWp{QT0&?u@!cKS zd44|0Gmbb?GO$hU?q=RBeQmGwUPGXCXNwUbJNLei2Mli?C}ap=hJx7vppJpRLfZsH zE}u_wqO_*Ov1k-uqL})$VP&5e9i) z-!7wWr(2pcuI@J0j=jWbZ!f79KS+gTVV`smk`yodfuO|r=wJEqiEOqR6Ce(cUYvU9 z*o5RPBFPn;=^rQa0+U~gqA!$Fmz}}JccFS{;PLppa;3$q$RK0?Y}l^$t5yN=4-&Y4 zaaV>uxv#U=b{gg7zE~K449o}yXIqzK9`}f`*V%ty`v}ktT00v&yB&`(63_dPlGqur zO^-oBO#CJ{*wPtKh8}5jVLtZo|96hHS5N7GnR+hjS!-`?wh*;(`dR=`+fP1ZNs0B!+|#)XhAOI;QTnsb z8Yks80VDu7g;%d5LhXQ!8!dNY@v8+WSA!60VrNxVxWA%Cts&8MFj)9;*g z>#bDQQwn$JfBCyPG-16vY_0V`)!Cq!fIEth5@skw*FOD451HE3*2T@{#eiC4h8L%P zy0$h0F{`H<(e~R*4^+*S!ODDX`F3q>=iS}fm#;@79xDgk^bCT1%@sk{QLug4Zw&K5)2356+XHBB7m;c~NULtz7@VZQAOj z%I`H!wy$bzAP(jIk<+pnLjsE-gwqhohY)nSd)Tz?494EckcPt(*=Gd2y;P8LEHW5~ zB)THmS`qqQTnyrPxl(nGzq8y#NQ`W(JO{sGnBLx}ceeTl2I=09=s|%d+9C8FVFkS= zau7e^vtL6!3_R>5p55I&y3M}mZGPqLeDLbZ1IHW(%_gzB$0k~5&?E`MX*)i0n;tOT zE*N>9?2rv)H4OQa_B*7t6ueV*omSq>M(2Bsrq2ayEWYKVcF13Q!{<2w9sR zkHzl)S1&L0X3GGlHUR=C2`M_3}Xci~OZL#C(mYcCCzrw=v&Ptg0)hKr?~C)fJG` zLK>7ctES4ALCbBiM3LCzy5D3Vjw$u_A);D3CBQj_RakdngFE37X3>f zf1}ANMfZcrM;RELMdYX|BFNhD!uQug5bo+Yo(=pIol63c$Al=MU>KPsuVLf>mibc7 z^nsy?vw_P2>*|I14o0I&GQ+^Kpob9D#nz)YYTVxOqqo=D68cZ34$NsKXm|RVWY*hs`G87 zd(sO6cyU;JF+VjzqXKK2_>Ze5Y(KWpr=!JGz0G(nq?Qa-eLU(+CdfPg9K(1rAAh)o zC_!Bz^I9PBY{vVT`?p@+VHb@*E_E~CY&juk|GcF@_@Mg@pLzB>-ATU2?d`;wr3tMM zXpkpJ$tVz-Yk6}}g7QYq+#jJ1S5{Vrdku8YdLA0<{LalG<~UCs_(-(8i4@EF+3XAN_%aM;;s}kpox|4DS8Q>83a01w;Q?_y2FNeAnl9nD((UYAKy59mhw; zB){kB({MR58JRO8{-pYct$vpLLyjK0Vg+Uh28l10sfMkBef3hjbMq4VPFG7iR}kRK z4+>oFtY7OKYepB}t#sdyjt*`j8V%-}{m|yXPYll-_w=Dq4EDlKO5@|$r9&xk`>u#kHoIU0VmK&o^f^p*^5fLN%V28i< znTs70D&yGiax>6pD>}(3|9u4>bDuzU<64#IfP;{wx-J&M?&OU?8X>mnQ)~ol&=3RB^@wy|N+a(;>Xu59I3B`ZRL-`xSnDRfjw1F0{ADW9jBU=chN&NwE- zms!O&-&#`1Z(6h`6%VE%7$bi|jH|p*`qh&|btwO8*kfd$7=%Q=jEXvMiIc2Uz*1yd z@8Fl=)&Dm+mMHiv>5?Vc)=Wf$#>}g;6J~V362<_~Z^(|c1HR}FaP}-k|BCn2xJdlp z11FuPkMtw^^z9E4ZqK?x2A&*QVy%7brC|u?eAo~TEd*5?lKtCGo~@D7EXEv^$s|J&QUtMRGEk>FI%J1|gAwH*SuEA~>>_{OMmW_W zU^)9Vur|xyArK?l%}X}@4b#$Q_!v;oOUmWQwVCpznX8GLB{qgpce+S1XD; zurI%H;QP0cEL>^<)(z6~c_^;wqclCq1Y-i`RsIM4zreT(tPoyO940DD>G0Lz-D|t> zXWFJNADibgg>td1p|*yN8WOEi3yEn9m%~9&UjP8d+9+ecs}Dw=D;Uk1w_ZbhRs1&! z+lilu@MCDcNG6OYTM`5gcviX|!0kiRL&Nm_^B5=@7LQLm@I$f^Hin;2Ts%NSjcRu9 z4x%vr&;NAQb?qR9$s9#b6D^+9_^r=EH?}ELgJ&ZL{k^8=q6T4~`e2H!Ay(*6r+ZH-M!nu}i zX9ZkCCQxe*zQ1K8FMMI*lD{~0VfTA!vYH34l9l7F?7iB=>CDw*Vy#)L#FruTaqjyH zDZ%=esFvg_7+!h9$y!?J3${m8aClZl!Ya^&9l*UC z6US1jzSaef>K;hw0%iV9rmRib33>w~0fxR7kVxK(Sz})ch_$^^>0|3h;vU0aCPYK| zZCxqtFk3V1jP2fT{TJ~dO2=Uf<{djY3*0X3C-_X8iG;}b@uR&spu%9X@j@SqXl_o- ze$5ojQSi^i9%Xh$TSZDkkR*-{lxWO|N{C%Lze-!rws_n8u4zhJQHg-HRztdZW8=>e z5n766r6#{}q(!m$agAb}$qY3EhWif?afUh-8YAi4>Nr&_&0H_gbZoP7e4PK>yRW8D|wv|Mpw88{1oKrVQ2{!LR z3e-|T2KCzHP_>TW0l<}`6hlRe-aIQVn|9`<8n}ua;eRtbd~Me9a%N)1s+_l}6{O0U z$w=z5hgvy5`XHLjntb5H&zC4hex` zu&ZDovC!_?Y+en@Ok4z5)2AQCK|Ac^qp0BrQRFf%<$aMtP=Ybb2DU@ruqbg^?=^h; z%Ip33spYkn5CnyZBpUtVAaZioveNpZp!1jYFvsj44h zx4{=T_wgV*Dq3hDKq%cpr6s^eTcwobQB-G#rtF0)cgEjK8s{8?Qsqr)*y4h1s2 z$4ey;m-x4xxcg9XwiJ85uxj-W56#pM0yy0Y4be2$ku}Cel3}f>jXk1NnZ$JCT)3o> zv&P?PE-8vY#gevv#BFOETOV{E55nB%V}wFPHpHsL_9O52KB6vN=3D9KH^i$vKFfbL zUz7XBwdivM&u%D5EuMZ~j=RDI==pkq*)`Km>v;bL6G>z9=yq)Y&V|do2Y{ppe*)nk zoF+3tJ*~s5saCHd8x5JlW36z)Z2AC=2-tK zV=Cm|#<%4Wwf%5$jx{{HP9eD#q+&><_+Jm=&AlC!n((pJw%92MAL%rOm%@etaS{p_ z9pTYY@1CSnWU^!if8wXX$y7bFrx||7$Shq9(Dvn(eTR-A3jGWHBB>OsiGwR>f(li) z=ESR>iyKiTpLR4{gBoF>i6=e^)Xht?t)6433)yCIV{JI1*W72h{e<2A=yA`(6)mMc zd-nCgS0c=wdENUPb{73Siq5U=KKwc(PWa@!Jv->-ohwVxC#tkiP0K)mRiMi0-wsSJ z0KoK_8jLt5H(;G$V%d3Wt(mo2V?H6E$XZW_S&9+f*0Jt_S#A)zQSZ2vZ(rm28>o#z z?nj}Xi(w5TdW{C^Z_u;Rhxj~-rX*fdRd2QYqf`C*oC#ATVE3a^V%@JY%lw#l32sfrJAvhI7hkM8s6wt`0T%H}0FD&awOk7eV z40z?MFVY8oj>|-iwz>y1vmpFAE3?YXGN0>x<}89zSea1{n0whDF*7cn zlSp5;p}5%4RImRmf*v*81!vs!*iY$vpjE5e(c)G6DXH$UIy1y>#|UHm%{Gd(neH?y z43ri~`xCNI%&o}{;8Z7Vq-?0Qg881=kQg(0Qj z%z4ErUSBm0(m!kY(0%x(afgDmHl59&>ELLuqnRt#3d5D+iIeX`7q^u{uHp2fd=jvT z4^CG37O?m-fDhl*{T5(qQ8ca@`}t>s`g@!wxIF#>jm9&yS4X{-oCf-#iIl}$d1Myx z4qB!m<$Sl_gtv`9yOnFalVXXI6^yAuF2)n=5}it$t?01Vm~+tT_w|vsQU|t0=^fHj zwxy~w40r+<$t`GQT6E9kx)tii;vtaXtWf+;k^r8P1z63!;gaeb3ku`^0_VBQ*&{5N z7~6MkEZlSv%n>{g&aZh{pb$ArO953tYS zFTFA#DRyH*i9t_>p`f8gThXQOG3UiNF2jts{BdEPso+9ORoXuA` zqb!Se5L$&Ml5yzkN>W00pJ<>+h|hS36~O1v_&UUEpP+M&;Xw(I8Kk6F6LKYQtkv05umHr=6->oTv= z3Uo=dsqaG1>810XarJ_t=~&A7_7u%7jmyV*L{ADvylj;(t0_w4$=xbMtHrC&Kw9yb z&<2w(9qVVi4}}v_gKdW0cwl@QO}sAV&xK$&y>?5eNt_U+C3-lSywGDpdx0fyFL0*( zFSdbs!t$uI%?QqnuI~dXb8B@SbbE8)+LtYbJx$87C$pnBqY)5I`&K*C|TCt7TJ^R{sr z^5K#vDt1&Fy~D!7+>H^=o;mx-7A^<~x9AcwLg;D-={$~YPP-A<|Co74Dtk>v3fo3Q zI)TTO5lyGw+yAOW>{@TfakM9IyO8Clz&bB}Cj5JVhnDG;9@ zpfz4D!(yUAtqCrcD)LpyvqgIpfAhFBI)2N6|O3sD(mTo$5jP&zo)NCFC0u% z-&}KD=Q5@&^EFkYD;T7k!)VYMTD8oR{0bV?T{LEG+tGZ zqr1@5kv27qZWvc8$(pjC%{{c$nfEluYhP&OVgcrEy>v3r%U=7EA6PX#kBFbQ)7Fa} z7b%maaG+~QA1Rfn^<1cJcO|o*Ukzy14KFoh$1(ITi8vK(!nz}_JFafHX81H|a|6*jfsAC{jclgj6 zz6*N(krR@eS#`Y`0y!xP+z3&XcNR%{Xc2j;WK%LKv_Fi7gua!+ZdYVhlkaWb;Vcv^ z?6Xk9Fn8+?2-#>0z=eueVz0yi>sA4a9jkw~M^gU(3WYvLxHHI`<}vu)7lMagb}KP! zje-6#CO<7!mSj5S2op4;6!RG)jD(}~3o(5zmo2U>S_E~G@8(s5c1UlA)I2H1p$>azI|sySDwJ_Z{uzo-oejXm+FI=vJ^FDqjPuIy#rb1%_ygiSCmSv%VG@0P z{H=kY*5(#Ht-VPG24#M4=TVzL<0v}`jYk7IuC1pXX-PBsYNa;q6e=E z_QHrb1^?&ale);V*qY5U*X1jcm#b)Qaoyb0J#)tlK0Z>t8xIO&D|C+`*(V zJUCbVG8V+N`knm2P`+%*>;nA;%jCwgy-0NK=JO1mvhUgm>DT0uLBlLQT>iDlo`U>O z1IT4V(>3Rkk-9#+eJGYlUY0 zNIsIeIdG@skANiH^t%C!j-;tGC>4*p|x(o+-0dF&iig@kbB>{chnk=c`E^&%) z@4Pk#bcSmel%0>#ES)FlQ1ycSa}%sw%0GGJ@FQITERQpyz6Pw)pHBc|KaED z&|}UDk~I{3i?xd}&=D6N)Ipbox;C|%qnf*MLZb*^6 zdsEXWu&v|UiMY7ka1zu1(<8k^oZyB_jpr>63_$4cq%sIQd(Q;?y4`Wzn=DH+w2I{; z2~!~wL!tk@);pKR>c)stPAwfR9lf}og!%W{=#+IaTGIZWK*@(D&R*mvQbDtoAV2xe zUnxCnZbMY;0z~cqtil1Zp0gF%Ie=9>*hOlNE5WEdVUUD`LmVtYE@?LdU%lKcn>pX% zh6`+}cV$KFN@ibwrD9lR1hz4G5H34s*-Fp+BGN!QJBUPhYh<6pyZ-9n#^PoTWqiK$m%tWl6+xSnclDY$>hg7stS;IBgRGD>2d4Zj9B%_WHG& z3x@uCwj%o4^ZeCKZ0S4ib`aH}W|JLC;yI{Jh%73A^D$L-KyBY+Z=zV#K8xFt z=ge&l!Eo3hiV!_Eqr$)(fQba+g*@#@+F^r<5C7}OfMh}x{50XXrP>QLt*^BfipvD_ z-Z?j0FO*r{-z?I9Upko;vfa}g*`MI^9m|kmAYe4dqX&=?n;EHoG`_`UHLV(GH2sSS z6UY5qA41ct|1}2hn${Z}F7qE;4ww?W4CYyl&^36gd2b<>I=Vgcs1-{S9!?JD#)O*k zeiGGBiz2fsjpK+jWqC{aDUP$W44qt@-%j(KPb${T(~y;vm7I|othEZrD8z_4ubx^g*+$?{yei ziB&t;72U0m8HsGb$^)>YtU@lLK-)pM(d%BWNn>Qnj5zhC`Yhm1?j74B#Bt8?<;C91 z($mrlP1w)k@-vFw`=H!{H8x5r`e0OHhG|5dKq?9(dZmwA2x|fB$(ae&nw<$WQ+BqrFadO+8&R^Z*rRw& zC|-?(uTAH=4V|foQ8eG%MScXR9-~mJ1{$9YF$FimXr($*!}-1p+b?$bo|$E*;s<}J z@L|IGBF55b8$25s$Y0ed-TTI`9Z}UXW-LTV|8ltqGa%u!PXe@vBMEJA8TlyMSnok)e{9AR`NA~O~nCeJcu+n(jSkcsCf4p8=JBKt_z}vJ__bO1oSsG1^?Laq~&N5#1+XbN)l$3FKw`Bz(Jed z$e!C_*7)8OV`p%SPj9tA1BX|CS!4EMx*ogDn~v|5LS_mUmEy+F*{2loe71VrlJo?1 zBg*mapg|vkEI@ z6Ke82DwQCexNk;p9Oq=3`QUjZe0*H=KzKxDBY=p1k*0v3TlwRzr=6~sFM8-4ET0~1(6#83h1>je?N-or<1YiCeysj z?IYC?%>K5O6FyYp1Z2Jy5&PqkKc_|$k``J@pR=>{>gNwtbbg+3g}t5kR#lIz{?wn7 z&F_<1n-NJP9H|8u_;anu#u6a^r@y%fwYd~fI9VlvC4|km!!WCpMm@6FSQJE*ktle1 zXNdWFA`JSLS)}u6F+D90?~E932Zz*;yK}icL~P(X{J(z({Ex03m@i;LaQcGO#S2$ zu&L#`^Gt$*@WuFz-YXGVlz!lxertjgNHKWCPKwqwH=NuOYi94h6@CqlipI0-uSn{m z+q4`0wOR$rC1*o*`zf)&G3Z{;+r1y!~Boz_b~Q$$neKj!TT9l zXY#3Oc#N~LcHj)<$>oL#H;uwkag6nDVFs@OZ}Tfg_&O< zhfQ<m*wr_yIP!qn{|SENMZ0IM7n_=zIjAM)+LfZ(f6{By%0Q zM*SJP!8JTH1|sosYS$N13T&a_ANxq1Bv((7B?KfGm+p-v>j!3WM+YZrhb8&UwkMTL zS@-uTz%F=~aA2$Sb4rw~+0qzBK1W zzCHe}jz`ISzu{j^%b-4GLCJlSD&S&J`ERaYJB8rjI+pwq8JEtfjr)WDA8UsG%A`e| zvV@s`$39jD8Y6p*xa-oV##$1jVk(de)v-dvLxb#sf2|(p;58q9LA_27}nl}|NbHF0)MQ0pTzku zYTPUkf0r@-s6JJ?@hjy1b<4D%0iUfV8?OD)+CFH6g_XV*vs(9D;+m1hoFlwGNF!vC zs3;_{+`SXVIaZ8Pin-~8DPK94J` z3{s-h)89fJk4~hwZDLLQajbUIw3zlqyIJP9?(L2M_-Tej@p(s~h9imkO<@JsazZA~ zTt#lcl=DKPQO2@mr(Xf|VAVM^_tDK-2Im0@;GcUMT=ZjcU4*91Tt1I9|Hd6|8a%ZG zqTti5q*8eIpWOvBSjRt%0x+f2{ndhRT)wHKOC^+dSL2#^W^O;kFo>s7R{qM;KoZJl z_)?e(z>dXXWJ#h)qB$tS4c-r)aCYNcP4NNWWy0JUv$G@m^O(bDS4H$NMv`#lkYt6i zVol!1v9D4ij8}4&;qrX0S0er`J{dRE&$e+Al9A86cn_Sg3%Be+Q{-zK5{iw(q@vOg zn|`6hVF=m11w(^teaLbJb~oH6Dw;KPCi29LoY%Uqr}JvmXdAIJziI=Xa|Aox5!Qt7 z>{-=tIIq7(UK_%P^(3ogm#!!HP%@y<@YV$G8wQ7I|4CE3u+IwM&|orCNuUlrx_gSh zG@KX^~&7pw^|)E}u(m5TijM5|>C&DdZv&4a*ydRp6GkY$#63v(-R}aJ3VO zeJ=)Yjm#z`hD!OoRa32G@_{49gL&Z3$ItkHFUK(icLM?0yJ{lcdR3V(h)SjS6{xg} zVw*miUq2ZmnBjgh@7G&GsKWTI1Yobi<_JM7+@6RkN0SJWiOA1YjqaZzRdC{K+nebk z2~SCRbr{BcnY(u9&Wbim>&SO8qr9?{43-*E!L~uNm5d#j$NRt}pXsE+@v%bAhGDTc z&(_aLQ@aW$8)MRYOoxC(3ar5f1i;_ssrCD_|RrWDdWb9X8ndSgJr zS=|A-6Ule3mLv6)ygke#1v!_F)+nFbYsRAb@ZC&x*YJU%vl|A<%zL;W|D_6D(Bl|M z!?Ay6-{QhA<*WKMqa8V{K>OvL^={B|EJtDFmRLoof_6!zm6oC#e2*OQMe?o} zY*9Je1Q-vA_vQ1aFp!})xZ;xL~iQ?AKmQrrZpRFPOe2a0Xt{5-*Ot7oc#Z@GPyC@gD-{SUA^99z^dtNx|vnHs1h( zcs$LTrmJMMJ}HOo6>^(ZsuX>ns<^oQ-LYZY+;MN7VB4}{o@Mv_WMlHY*}P##T_+AP zl1f3ENT;YWHIjA^&L)ge60zDvNS)>Vsbg5WcEYS5Ev!@mCuG^{GiPnTIXhF};Op!_ z)|4`LzOdGRMqjIdFed`65P>2cR5=i9s;zrWG8xU4ZjQ>?(>&E8RWlVcY>kjMl}-sj z_7R{(NDh#coc;K8qu=GjMg}CwF^n$h&l`^s_hq~_$tDkf{dHT=KqsQ0;?Be>CA|x2 zd+myr`J0oT-2`ua^HTAkA=-NNfG?`i}d-cTjn zV)s3|HCMjKw4l7xT2kA`^3z#dkqvWi!Kza~zKUM{esXJKw@`+6AU3hIaW+`&RD+mD9MF=uZMWz=$oigVug#g1 z5e-L9V0?G5uqw(*xsO$*g39FSl6{V1A^cjlAHk>)Q~R^}*`ik|n52f_7VlH_LA4(% zt>VCFP8A7xBjxc}6V$`luZ6c?T1@^Xwu7TQr-TL}A#o+O9H5AyFZ67DX+IeV_$qyWxGp;0boP{{7#x?Q1m}X~?!YfM6_47d&XOjy0 zVdyfJ<}kU6r9MLCa3#aascC|F#lmPMhk`u%UCDL~7Rn4m$q|2-;OG+QU1>|(kd>UJ@f|^ZszyF7CI=oLZT(#Wr!I7?B95G zvR4#;%EA*(N8fWdIhS6lO%QA*RlLW19TJTRvU<0DZ(D&vsfWU_BNLBZ>k0=rMc2j+ z^f|1m_~9obM&ppyinD@q_W6t}!zYb=AlpWpB;w|v)&t!5d>8B8T5^%jcWKD3lJ~Vi z#U}ELbiO>T6^-x8e$ZJHu1!zKa-0+Otcc$>FWc;mJ@<5A6(8I$B59NI*S%jDSpQ3& z#(!}_Rl2?{<$Uz~FbnBQvqpb9(Vo*}f$5a#l(nDPOVJdnsxX{IPUz6+W?jZF__S}> zfxdOztt2XiGD83^`qjQ_1o#P4Se)E>dCV$MHvc}nwJ<9pDATqr80nM z)>!b6!G0g~7fciP-Usk2@;Be~uB4`MT*q##y!7-lcT2TwinLF5=+iAI&GxFvN%t>W z$afYPPHn?X)zG#pJHq5+;b+8xqF|#DB#&TD#&#|%%@&|bwQzPAPS@7egfn|z-&Ioi zk+g{)Ef%sPa~E3aJp6l_-UV67suGY7@{ZR9ssTqT8l!{BNHv8kb*FKsaf8z({WrGo zT?a>I{+QMY54>F;pj-3hdD@ zuK|aDnB%HdfJIVoRM3m6G0G>#aO`(SW0A8`XVupR4hyV?QVFF(P2 z$M28zHlw_RBbGBty?5T@bLqt@`v~t2*O2u_1)|<*Szl?M{`1XqE|l2~prQNmk)!o* zZLqVrHokZ^frkP9TKyX_^(54*VeOdN#AJ9aG>>%_r{C?<^J}hcn$;>_gO4yuM#RJj zUg3x2%pE?uVY z-Z!@Cp({sEu~(_kP~hAY%zjYgc|^_;&dj=TF9(U8(gg-UmeaTjut>mMfLa)l}Gr+ z7!K?6Kd*C1gD92lILQcjR3u=#K2Y7n*G0lp-n@Urryg9p>9gwlba#Cl3WS|qZslm^ z2&9I3HBB-qRl1sz;?YF6@pUc_4ZV)F^lt${{T>;yU{@fAw)>#J^lYKjt%#noxXHi z>c*YO;|7^uj@DT`eI(UEDV^?3|MbwDvMS>9o9=nvMDJ%N6Tl(K`y#fA^*%sUY%Y6o zTLUSSFi#+b9Gv%qSr2oo9NuqR>XeP~T`f>56x8?grQm@F7o8Qw7fvphGvcR$)KP4v z9_xeFYA2Jy=-k-#mBaEDDP~9hdRF*CKkG*IJ>wpyt!?0UBFhPh6S0!CJhk3fK1UCw zZ*&&oe{#zekE9mpC4aM2goY}yrez_F6GEZ`nv%Ew?8^P;*Y2R%pn|8pouw}A>WXTQ zBjqDV7jm(x3Q-x?TQWgUW1)LAhd6pYg8NIRa-h#oOWl{yXKTpG=K6(uCsh>+g>@Od z1NY3>Cd8eQ97sae4I(Gc=>o#8Z}ub|y?;Lhult|-pG&WYt)sBgF<4hkrGw5gJW7$C z#8)qB1Amu-M;D3@hn}OC<8U~3)dSxL7iW)9;2FRIbf22A6yHNcMQ}bN)HngL=n(9tsKekpA^SHdy$giq0 zD4lv1`RViSE3SXryXK`IWNsZBgxiag=C5P6RbNgJrrBPxS_2n;k^lR{04IB|J*^H4 zb$8=+n9{G=g>N7rt`PSx^@ydh{QD99(p9!Xy^!zF03m%jmPyKzzCSZ|Oj@DR$q~7= zvx%*GIiAXN*b{x&F#!F}EGoqNswpFERnn+Yc9_)57^l`@FGE44 zP}s+$!G%`ZOuX;uaT6-!87>sH^eOb4h08VK4d(6fe+URlZv$W@fxi-)6?_iLg(wz# zs^RzrxoGgqNtpMWkBxMW3KVB3x?$$Po|j5sN5D(iv-SOsu>IoO-*SdYz7n|m#(@-X zE_AOiUaqgNPrxVFPp^<#m@HOrv)tLF&Mx(Lb&vXbVPZlxv&!Gv(c%nWs?SvF|?9BS6e9tKoH6;2HSMCc3 zJkvSCfHooy+h6m{HZ~Oh66oIJqKWZk7VC??au_2xb8Z2IjQmB!!ZX@t){-zn3e z&KN!zmROY7JZnewU_Y))@FA=sD$`3!F&6m)9@zoQ&%Wvbaaymc)$KH8Xz_ILzpAK`~Srn2gJH2dPTu%9zbk^ z|Bv5~j!Hm7kndivmp62p0&)CLg@$?x@d->L)=(D}nCAzAH2f8tcDGWVpTEk9Hhm~fBx|DsGj#i5VA&7}}mSjv$_axXt%6e8*6`|q6xtwma$MF+`{6a1zD0 z8b}umggHNhfRBP?Ige~_Eao>OvJPS{)45kEGHlDV>YU*u9V$-Txdx>_hMKE_tO8sF zI2f@jU@UmUhzn%po9v0APfBj}eR6VS23Y0T8rxyQ#SwmyZGXS@Fq!7EM?c!O|K*E2 zsGCY;OqD7vkM=J_({T?vh22NDe#HEXai~4B)D(O2o#3SJ0H-S&%%tNe2bY(&duQYK z1q+Z)Ki8^0nHA`5BrMNs|D!KqPBxdZ(_dow_dfc(8w*JwRmBD}qc-ESzGXoDq;n<(T_ByJy!AUIkubKu4Fyx~j=zCwge(x)BPr z87=hC=aPIQ{%A4KIw)6SN(y8x>-Z;-IEW;v#UZm|NI|vkuHPX*eh7Zh91KJ&xT`13A2TAEH4T1GTLT^8s}cHlTK|P zJzH2i>FlSwDDRFT(1iwT|EiHWA**|*z@EH@S>Joo-O-pps63d`+|x!kxWBVC>*U);H?aDH zpeol^zZ))4?lJ!w*KR`fw0lFyyya2Dnwso865tlF9O9SUPfej2ha?qXHsFS%XGCJ4 zDg5gRKxR+VgutUI@3uwcU%e1Y+PiRQOccf*M1jFdU_Ex@-uTkV;xx2umBn$k5RNlZ zDE-A!Ui)8%{>=%_5lxapCJq%Y^Wq+G;A0R|O8=o266$bJ_&FllIDT#Av>rW#h^Xg3 zSMO-1JFH8L4aD`3;@l^`1AYId$^eVC6c=>eH3MqZEZZ!vm)(grKYP2uz%|_huP<8} ziaE0v8X1UsIQM_ok7DRv4$e+M-8tmlzFUGmO(o>}*~3sFaKO!WSa)oG^GO||t|PGG z0a$CRYIM&P|MP2Hh3k0+omB3}ph@4UW6Sb8wR1XVEl0{jq=-R~; zj+>R*7SavxHV*}G*v6i&V=O*IM(_36`DTb|#Pm@fi~}J*t#r=j2sh z!OLN{`y$PI5}9m%%hS|kmtdaVmO%A;hqUkS)P;7XMx`-ODm8-MD@1bo<+D`J02;F6 zVp0)~Cb3xqZrKX`WNM_rS0-#)+S&9)Yv_|OulCB#r1p`A^xO31bDdmsyuj@MfSXG7 z41NKWc2>Z`A5KsVS_@X^9V!(3&tXm2MfgYUZ^dmNE3j4C)uClBf~t#5NjBVVb1NmL zFCK2Nr4mI<7Injc*Nx5AeC0a^f%cx(I-F3mF$7;$Ju9MeYPm_Kd-A^2B;?0! zad~SJLa<+FF<@F-ahi)ApRGaF#9vDa?$b`Aq=ZheQ+!RlCF{;hR39# zqbwnFBD2gOd$JRq*4SWuBt$_gWJ5XGi%MdNiClhy?yyhI?xB>#ruBJESO0^zf0|Jw zVI9DI!;e=Y(lB0{0?;#E*}R?mXo%j5P{CG&u<)k8ZI)DP5NFV^QhsMr4`%9sEp3DX zeO~W&fKZD^5YQ|~;P@iBiOPMwd+<7_gzjaFZRjn^hf=z`yZL-aFoCkrs8Rm)j}Py? zsN8~hg%yJ8M_($WJ*8g7xo$9$fIKfQrF6r1Dm|ERtz^UwS)_R&ixSU+oElu zyrYqD-vB&pRJM1We)wUGtiov~e*y11ilHzhfEWdt;k3^+mWBYK+0}A6_iFpUCeawT zrqx}YbyE3t&kyx=GACbq+8S-p?eK&}KkCfiv#L@&v~Mfza@n~w_KXEgNe4faRwPIu z{-P21C==pz7CX+8fz*39Wm2L8_!&wtuX!%61#NF9jK!BjQfFa1AGTDfPE%jB>Jh1x zqlwjJRIni@($mEcAN%cQo>fLo`OTE`1P{mZNGm9@m^yD@du01bRL1X30<-!j_q=sM zC}o)^){YsFX{oDt@N)xu_VRYj#}9tuXC~+7gHIzY-UhJ}0a*t&H#+%jpgml-d1oxQojIVKDHp*&uUx1e9x>pTxLh0yrcZ{ zc58A~Uol)qfb+|n@0zH zZ0pZHrv}R(qF^oUk8K~QC0RIt3bwI>pMpD+4ZE&xqC7YGISHm#?YR^`Ot7Y}M*f($ z7az*2hJMo_2cXKaP9rVo@yY9`;6+wT@fkxG9BVRim%RSUd+j(x9PYGF@h8fXk}Vg1 zW@p3+GJwOvTCZ@G_?X)I8=p>6MYhPphw@u?E%h!Tjr2&hDFb3NZVLfr-L8#xtEw;T zWbSz<8`S()TeDrrQpC+IoI%?xV(Q=mZ8e(2N3k<9OAjHAZ|(p?IKeS6`~BT z3>F$po|o2Ds4MKc1_*h%g@NC&Go)r=2)R?IZYGHes60Ct@d{~reHl9$OF6PP?C$i2 zy)?p%V+@sF{d_2Y)bTEO!o;&6y4Y{s!z4v+W63MrmXj|&lBk_a$r7PEt2S3cL! z93{5nbA-Rt%$|EfmRK*l8 zsc$mDSBt#97-LbN-5+iQytr=aBYsr&%n(_iZ7Pb+3d8y=?B37%OQ;|N#0$taIa2BK zldew3+1rzJ&j_onNXcUI=L%P6XtsyBXQM~p7k_-S@Ksaj8H>vTqn+0 z$0CtF$xOPLs%oc__rOe>+p#G2o;;RGq(M^>i;9wtr3?kz1>&&^AR{<*{MzWhOc-?? zLl4+hC)?vE!2Elj!3Pg#)gzb$))e8XB4?Uq;?Pw9B`ge0Yz)QZkVB6Q*MN{DA8+;y zF3Wy7#Js-QTLUOm=k2Yi$*lv zeLYiwOuR40joLH3-`$K{qus1Vi9|##QYiH!KvT&f%J>c7_?y&CH&+W%z@HtfNh$vs z4(I+kTM?N^RUpk^UiAmZV(BHNBPAuDTZiETQ&G)V3ZwF(P_c4BmWh&SF|kUN`OL>; za0j&)?c&n^^w}39uLHgXV@~}k;ya1Ihp`aS@~J_xw?@2`dFfyZ76N;eWB!+!O&Yc2 z2OU$8VV;)b9%2td!=B7cc{w4mxN@?e2B%+F-U|Wm^cK69M%ICUkXBsj>7aH1su+Os zqBlyVhQbL~OHw?n9`R%3j#z_nQt`fJZ`jU+OiIW8Ug*Wx<^i4c*qpKg#Zc*=!9wOT z+);JfUgiP2Oms9fjj&pYM9VRZ?CH=b78oI}y5Cznk7e}g=ff7a<>`ZR8B}vcuwozs zT5nLda(d3HD`@kZ{F3}q&{s=vJ?%a{q3|7p*WeWSSW1fhR(JR7)2_l+;cImL zD3t$s>jSh0kJ_vUQuRf!)Taw;__itfZ(w765u8JcJbmc`F`l9zaccGC8gXWTqDIx*kn;^t$Atr?%s1(vs`N9lsh-Wvs@N~ zKm_5dx#2**;ycuBU=sD0`^!gSbna?Yo>u-DGXuw#@iM}pQfe?!R)3VA)wwz!5xmw< z>k*X5UaO+Hpr~&;4G)*^#3bg-s|5K>L-#IS7;opw=s%p1gqhSKGVNLeWNW#QRssXk ziG`Xzz?;Ud%vIt6sNkvogK#4X`8ASww`uUX!V4mr+d0XA?H_=Uq@U_((E1gJdaf0S z)r-SpZAbUk`R@~}qNwWMsD7i^Y+m4-&ZnFEr@OtAMws0#h@MPYpn$K3Y$tj@eY3kN z7iMr9N)q?kF=<{=6>vw&GlO&?2acy2PUu*9sO@)Sy*(js%4=gxK2j_Iv)WnkMARwR zCqNYSsPC06SBE3bVMD3wib^<<9B`AI$Y8}8?7<5OI06j(R^&+XZ@olO)&O+U4fe6c z!KSYj!Ded*8uf%=+q?P)0A%3nSYS}nYc1j%*`T_txfijxv$GJ|m4d69HAC4RP+5Ls zQy~|e)+?vv*E~Qsp(r@NB;P(d&sf0xRieR|imHl1-o0gxT(CHF?>FqGR&d`;IhILx z2{&+>NSQmackD_ANOCzWbPjnr_}%ce|&gH*ZMK9?qz`sueBLMC0Z%{9nmjo z_LIz|>vL?SpdE-zz<1rpefXQT37WOOZOG)zHMQc@;QtT1vN71UE>1q3e?G@tPT_eG z?8S@C?}NDQ3)mLfT=4Tsza#Y=Kfp?Iw)l>8V%kJl2;sK3U7NCXlrV;&KMKg1cuW;( z;S4*wa|b*o;#HT%tkd$3U&XZVg_&`NMPM5n2=Tmzt~Yq60dT)wr|vi}PZk?ww`9(E ziQ5ecHd&QVFqt-1qNH`a-xfU%GmAn$X15`UWPOv@#UVClZ)pjeml*(*g}08cOIb4* zBWWm<^*2DX)9z(#m;O$w<*UcXE+z>tn-7v-PFy#7dbIJ9$XJZQFUm;2;ROn$vV0xE zBrFo?*&`KED&6n@?{c|YBs=}{p}Ltxuh9?n>P&D0=~@fhIRl@8QXa>!^!wxluOuJ2 z??79S0PyMQt<*i73pMRf{#QFCR9RpWUDE00%_rdN<>~qQ+Z%kUu%*P;C(z&3b2;MK z3H$rywH!*bX@_L^+4mqUc{KB??$Z>3K{58Mq z+}6M(4mwy=9jR7LFIPTX9FOqHsG%Ip*b+tGQen4;KX#~=tnXmeVw3O zA!n~)mBd)Eu%W=9UNx#c>ZYsiEu}c5uZ=2|ttD2O5MM;08Ou0BPb;c(Tu?&$LIbZa z$)4G%;m8YB`CsAj=NfZ1)2E}!Is2QWvz?*O3VC&Fcco50+rtV_!fMcb(7Yg?w1j;2 zzT{%#C^RNV0OAT;Iy&zUG^o+GHjV|D3A3?Xu06i4wH%cU*@u>}4enHob@vPOU;e~& z2qlIkXnGsYa{;9Jk%edqqsT7owE*L#%CbZ6nG0bzsph~?z7f93uB>4%cFrD82pMX7 zF0P&fQzV5D&tSAYv8I4gQWdsyDJSR%krbI`{TRAu_Fw@k^dOaxoxYMm3!kb+05l;9 zsTUxz_4OM2eQEq|UISWMY9#a;T0giN*R?<+@DDFg@dqu(+>pzgo@2vx!VZ`YWy23X-he&|b0 zTXz+vP{a$JtyD>&$|6GsR~jz|x!kj3C*<|&daoPi0|NnV1^7}gBxpi#afF%+_hx*6 z%us0k+i2W9|y&KCYX9x$egLCb=B z$FZ*!qJnHk9T8$!QWhFrX`zbU^^+`m4AkxB9<%w7c%2*GA#XM~Pt)U&^3@@`(6|3K zz}jnE>n@%Ijp$vvmj3W$ZOBPJjJFI%FaO>I?5G4#GX>DX)u9##i=knY;@XpVzkjcs zHxC{0mW`5@vr3$ivstv3!PW>@6IpADk6E43Aqm5&11F3*yWzS8O`*H_^!g*L#$}8J2 zUQqrZ}zYnQ?8^J=^5>B#)xegpFxV`X>oKm?dEaNo&@;# zRaeY#NHy+;BbQ%xt9dJuq9)Ty$#C`JAE@mjU{eg;$1yUC_3dg<;a>(W z7vQpaI8tQc$OM?Y7yCn6_njJl7oebBRpb zAA2IehfEQu)38~(&#EtIb1C5a_E3V}t~Nqy?)n22a5U2Y3~Tdb9bd0NAWctiy;(z; zuea~30U5cn=l;~kZ|tukEV|0Q5bIrgad1+X6ztFCQT!rBJGGJJ4*+_A$hMDD`XL3e zwXzbu=axU?m=Kf2dwNtF4pG|vLKz-f0Rnt#l`+B|PIeL0>1SwKxdJ0Ze8%Rv37h_- z2P3RlMcG()yrCUuCql(#`PADXeRaug|6`TS<85MCGg zi#bkFYMTHa8F3RA#Bvbm-cxdz<~9?)lZeEhBnm9)3#5PHl70Yvq+$uUjlI~=OF)0T zUkatJyYcX?t_7htSeit=sFIo=p*PLnEosV0eGeW#_Y6h7q%&v5xE;isL$H%qlxvHF_SD56(c@SL7x|^W4^JAtv{Ma)J~4}^vMJtr+LsIN zqgCki`|9wniL&XNbiKjt@2p>rILc; zW3fmWWOtQ?C=XQwjE(N1b?G02{-lKc&!4|+Qc|T2$pn~pR_DPA&n*3@iljfyMj=k8 z-_wr#?g-sDnmX$iD00)yqODY`_~bl#{bUMz1iil1Q?Ksctcrq0IU}Uo3jUiwVS2_W z82;^^PH%Yg;}!yEtMxvcq(kRX5IBXa*k9-<(2?cc{;3|U72&&T!2s{A3Ii!{%QxhDz4y3T|JsgNE4h;I2ca-`h;45^LIG_97?z zGL}?(<8IIHNAF`V58$u-?A|lYsA}lob?sIToB6vtfW@Se%Up0Fu>p$Tuxr}1c{pP^ z*U>xFlU~KPvKB8w@eOw4+Q!j`??HEVBkRo{Z2xbx+u9_JW%wTT6s6C3fzjr;y9$=_ z2)U1GQgEx7xPTe5Z?#P4k!BzarAKgASCy6GC0`w4|Mi{{m$vo5$vXFZ-}WnUuf7>tSKd=KM)3*k}@*8ikgYl~mzt z+;_6~wr+XlakQyHVPk;xK?k`?Y0G*PM;XS3Ayb&Bk2JR|w=9F~?7%NGTuqE#tEO2u zw~83@<{^l_M{5h1EUB=*MU%}xK9SN%Y;|jm<$GBZQtFlfd&h>6CnL6OV5uwN|03Bp z8-C=NXLD>uZ37CD0oGG0q`q}jPw+CDWSUPrTUIu=Hb9r4r4Idi0|pbu`-u91o#QvM zD(p6=f2l_CC8fp;sy+%*V}hyG3;ujyqfr(D{A%ky2F|RA_kpf@?5!Im7p&(ggTO5Y zWz4Rq0L-JG+0s&H`hSaPjFd%qQJTA?Utl~x(&HT80sJ`+Wmt;;ws{io1J!F6wocWw zcgA8wDkRZ0z&iG;TkkWz3a0lH;(pSv1Ue8DIMsZ3-|Ih5_pjr%wZ|QX> zNWZ1Id}i#dQZcoU+98ds@2ga{AnjV!moSAntL8Xrs}?SD!AgT{P?x=+0RPdV4wY?iQ%U?(I(<%E5S2bl?QQg zu@o;cA?n}Gtf8Bjnz`$*j?S?E0vo#DNA3dK0|OlONyh!>bV}ID2hm!o_VS@(=zGJ! z-(|_aYR$hDayr6uJS5_Vy1?pvJ$3BeER%_j8b{DZ`u7OpOd$=McJPu zl|Cx;fzi-?S&$!Zn&LS@h4rtWrrj^&CE#JaU4r<}hzi&r$nGmoEns?|fsBwGod-!G zW6{z13m9HT6o(tFkCr|8CFsqER(_kr2MHrm(j^kq3$`BlKyT^=FUc=XW)2kT4Ts5w zvvdw}Mzc1XLkxXR6z}ALyzwwC#7A<1ihqCTk>Le|D!?1VWlQPFeDL|{s67#UwVx46v#lBJQN3I>WH;mC*(0bWS&+3eE^`U z0qKyLy`g*g#owRA^ir4A)g$Jw=oVv1j1w2+LZn1#1{u6Oy1ZWN)-E*GL&L1I?rZpp1wrYzLxtx*07My43U0I=V;XDso=w^()MiB$n%H@& zlrKKuzk*p9UYeJK7O<$@5=DqO*kI*sb?zl4D!XUu6 z1rEFmoj$J!Jvz~(@CKhV?nc`C{CScel-Qz^jZ5)nj@;;Lg&t4DLysaKOwV8P06IqD zR88{XTJhK_eFyt*ETw8b63mwp@*(mm@QzY)@SjF0iK<@>8sq}hM@Un)h@!1YBTD{Qu?kuiHjAS+2{tV{TejcN zCU{0PMhR8)T0(?M*7-XVrxZ)(c8*60)P{3N41hd$&$hPR)3lH(5^yQ#P_4b&eM?+X zbHDiNMH6y3&el~NuMH*mGUpx&C`gf8i#dcU+t&0_zS`g>M5hk1;k#zJ%Lly!@G1Ah z?^#}X84Pi!_UZ4S^IdlJ-s2XDYNgp6?5$(FJm6nKj6rHrp^=Cv6mj>p^HZ|=(kk&| zKNTm*BIcmATY`c)*#_rO3J--z4Ur+8IjbrGfz&DT*$ul4)5p>Ey3N?ha^hTUj}H=E z{ybJ4pYy4)6|A(tD>K}KG^9_C&c@659d~&Jzn6_?kZB`K%A81pzcS8 zK^aHefF3vkRixwk99pd?!}P_y%)L$d)vFCJe^Cb5VrPCG+;JyY?yR@{`8_e|giLKx z=y7_+cMyRmyhG^HI*>KT!!_0~vyp%$IJBswo|urK5&34EP9y~!{-ILJ^RvwSgk&=Q z!2b2y(Np9jEo^z4FxlMzv4mf(1&J>z7mA@rDMv3uLmQ7T|Hslfa97rKOZddLZKq>( z+_7!jwr$(Coeny-jgD>G$<6on9p@M9GxnH!t(x^zO%!;?z2kNVi3f~hE>_hOy28?E_ZRu0N`!?4VAg_D#~4lIb@=3ToACe5 z#gO&18FJ3dZG)xy7~pl^)a_YB045M~S~%`6CH zwNy8VRy7C1z-5q=tH|H1SS6_n*^4Bh3r}9Yf}nAr_zj^zukgundDZ3JExVkloMNS7(K#og zT5G^!%XT_ny8lu~cV}(CzA}$Kh)qr6OstH|(u;^2Uh1PxEKK|Bks$pW4f&s^`%fmU zpPAB_VFU=nKoiUDm-ZJuTM_J8)I-FB1_f8*M)hx3gr_rEgwYSYT4sgzhBYdO*PGog zujZ|8JOq0B$9vc};M%tniPi+Il|D6|k5nzo+u)pmqa>gs=bWVZo^_<3@nBfALq)$@ zT~OVWu|vnnq%oElVG?KKpGMbjWVJ~d`0ggf;}`fQ#9(W;FZ2l2BcX`$dV^&*xiT1g#MJE%C!m0y_TR5{hb*I+D`E zC^k5IOmdQlLxJgu!BR<7aqT)D3fh)Bjj$aN(rI>0aEYpe*-mR#!QGw9jEXGQ{|}v7{B!mDfw3kDI;M z%c&}_$7vxU;<^<0ws$u=0^cm1oTtGPFdJEiprknd4grhNFNy0?@ej*~Gx5aQm-HUx z{bh7qyav?|uJU;@i}qKhi&8&7wVB19d>WA>Zb`S+vI91aH+}PdWWqckDVoD22*3lE zCEi^7t2|F^kUsh#^OPCJN^WPR(QfhzAGnUQqzuYpAID^_aUCCf*OY*s>oV5!i%@G9 z(2KJu_Ph_O?P)#w?F9%uLx92aq3e~-HH1_Ka^B7_`iYIV#Wx4_fZ$zF>0jE(WPyXC|MnFL{^5(M1zpj%bf)}qWf0wGX`Mn6RhWj`y=-wh!8rh(y*NKYr)lBerYZ1rU!Yp zHZj^nLpbj?IypfLYE&G4U&rdkVf~6A_x69uDt1fxZNbq6EXB?yOJ`Qi8WiRwn(P3@ zNZv~R?np@PNbNi%?`#je8llTDWQK+U&`71LN<5ToaIItFKvG(oaD}9r1D*I-ok?%>+ z$-bxYqXwQltk%F=$2EC(J#yOw`h2JI=34X!RQtGniNR<3&2@*YCw09pZOQetq#5mR zFymJPhv~E(vtvEG#txlcN|v$^K5i5dIy4L_1BME<+M#o_{ZU9vcmzS2hJd(2-1sQI zRvRB4{M*OPK`}>;*2&BR*xsUqZOe&CX32^~W|9-MOc-3Exi{b)G4Bf9H+Vpi7V}?J zK(0|zqnaw0l7%UGvBq)fToFs7esY|I_hW8R=7Wc`Z>mqnd?p+j6qT@$Qc4rw%oD-o zn_>MHfz?f)jch{V_-OKe#*Z;&rrT)9w&fOli%-D3NT%8~N9AN_3>D^39v7j=;7PFw z$UYWrd?*r2hlG#tXZy8k-MMf{%yfa%Gz}SYJV4>2_KrA0t-iu|kx}n{s%!@S>+V#D zuS3A+g+&kCoz>{~HD(R=jxf6?3Q1g3k^PXc9XbR5q+S@@UL^t%JsprPQT1S<0v`(Y z@4hl=K!)I=&dLG?koLsW@UBB(Y2sTWeZZ>{n4h>4hO9kn&0L|MLu(=p!l1b?X9q6A z@O(KfG}RBGiZfZNWD}DXNmp0(-&0B%@fz{aTK<~!3>(<})AnDYwV=*2C#?VU0DBPB z-rJpXz(;Ls&I_BanaN~UoNt-X$pq=yqDSLq)$dHBKPu_^+P2okgLj+WzD?gU$cN9> z7|*!>zLk|@Wn_%2$+p9 zpL_v~sd~Otr;`_0f|5g%<)-Mzi1YZG*|OC5FySgn_7P>N*R$`9(rOcY+FId|g zj4V^BV>msw@!Tggd^W10!jpwG(5G+1M$I<~jZou7B|AOb|M{I@AUZ0EY|eK>J^!tu ztyQa>CCjK(DeSV0d`g=__E-pT29(mgNV-<4;s0ZqZCmpzPO9lxfUXe)#{|DniWRII zSTt*YXHME=SAXaBU*&eZcPD@J@_vpG5X26r1}1qXfQZX{i>u?GVjv$+EPk^>xxlTB zQilFEb0oUYP4!S5u?ZA(->!22_SJO(%2yn(t1Dp<3v8&3^<~G60}uYuDZqJA(y~Ok#3j?HWpe3kg4A+}<{NaX!UI+^Vt{`Jv~*S* z3=#YTUfF!{7DKKcpNBPBo#(M}&HOypea`^=-@aPxMxE-VKNqhyna1h*gUtfu-=hRK zG|=1qXXiP>i#h!P{-xN6e*!l|-t3!gF`wW~?u;S^OQ;^!;M26AUx~)e(eWVnqp9G> zwZ<_ss@y(R_2OqONe^>}a74JdKDtiHK&<@W!mKJDPR7i=@y{#sPHx-J&SM}qyy+ck zr}-#|zXq`(ExH4J>~@#gO?e$J2#hHKv0idIcJODmf8{Fg<`g; zg$ciEQOoctS_6~b8vYV|zrS7FIYmv2kYbFbP_|1ZjWQ~g&IvY2>WQObZ}GKIW_lI) zBw(?^j(!I!KT!1r7%9j6hQjQt?Qn^PrRhq|MRbZme*Gh0Rc9u zik89mUBQ>sBSnG}x=yu|n>IrEd=AIMc=(0y0W(f#lHSJ4SoG z>`D0t8)093wM~O_8>;X4_$Oga<0+0)YS66zNTSQFE4%mZ@*%^DFV&w4qAeteaG!Nu=}}xyv|53Q>vJL zG}X3>7$Z*eonOYnx}srQy3KbSfKSjSY2kC_6H8LZ)1RN58t(SG%^OQj~C_CX~ZD#pB3UMv> zGg&#FWaD)RRw9u+Xm2oQiC7*YjyGM0zJ6`Hc}7~CP-t&DM0z0M9VTYXUbRC8JX8{E zS+%qHAu6dO1<)`7w06ciXd}4k@A}&F5S1dL@=0gK6Ecj7CnQdylq8I>@xmm4%NkkN zxs78jg<&HQ^Dr4}G%4M$>9q?+5owRVPg?LHfFT&5`l$p`X_Q3rv11oLwC{AV5o`G* zV|QdvWKE^2Es-Z?p-(-HlLG5+GWvbeH%E93zI2Y6KNsI1my7TT(B9_{?ek_65Q7<7 z2NE^OGDU&Lu{YvbKLa=2cjIXlbY0N19liWRfgxW(@!UB&JrLLOv;7ZKa^D}aK z!}bY?c8HiGn?et}Xy;Fu511SziI01m=EHTMPH=WtmM}l|6v>RAX_DCc-mf6CTJhX{ zvHWH|YkYGoUc1800~4VKC$m6q~cMde@(v@EeM_gEYPz#6hm;I5v48o6B!xwP6GdkMT3qdb7RQLYAC?P;RxEm z{GA^Ul-k>4w|+KdqD3OME&$V5i7ZrH$Y3;ujee|P#M<5AQ7HMXwtC=1Er^4zLlH*H85%NTPt}`oU3swFHIH%hgd$%$u@^ z52rQh$?<$F5gzakB}|5?ku7metCsBp6;tVYSMbB6Co%e?L=*_UZn?T|JI45O@rsFz zY+as8FN!(%P-eyX3^VtJfvCH{VaBMu(_cP=d{Do5vHgPdWOfHFMkXwpFRmFT7bA(o{dQflu`Fh&$l|(}B83JNZ zpi?%zY8LTzYHZe0CZkL}Lb-wY8Zb9)fhgThf?bL}oRE;;WmhJ-8;%EaOC@#wFDVvR9knGa%D>UZ=9&Jtcmz1icl@T&k(CdBDG2C@wp&nNlEa~fJ@n@ zo&UN;rD7qrT;)9|ulAfzA_R1+DYm+Cus(ivWM0QSGGT>^T~b z)6s&B%$?MJRJS$ADx()3_fg;&40CX8OEl0YxgW}~93l*fLO{;hn{U+NvH!kFDs)&K z`vRw6j33-p_{RR`h$p3OVW|E0?&%;3X;^q_62_&n>{RZ2}FlqSg?rYfNo70%}k;Lp#jmG>8mxsF7x^ZVB4+D|v@=pjW*Wxdzdb zI&%I-={u!rTt^1%C($$nTgY!sZ`V{SSM`MIB=uj_V#P&L7hvUFS$go`z`3Dy%Q9j%Hnr#z)FlN zD7OW=`!#18-cLZy9*V~xGuMGstsIPd_)%cyH`T{j%*`mSqBxj50&r2?B?2vl@sEZ* z^Lc?9eyN?ZSr^1w@{bdICEP&R63K|{q%^~Jv@!C?4)rf5O59f)^Z2C!MO{@2f@`c> zTQ@d|Mk^5y&{;?n7q3?>;3_CC-FH>66EYwrbmm=VR;d5P>ed>KZT|_!3+SpQ(E#d1 z>O`zWY{MiKiFjyKkU??zzXf%_n+$$XKbPAIJv4pYWR{xR7I6n|^?FtOj*x5qJDv5W zwDA5}-1n8*2PlUa{cMy;Byf^?1?2gzU-Q=LOmyAVF#F{~g^^Tf{S_XYFL!fyp`T7T zx<1lC!V;`RyI`|uWHis9?j)88M=_XZ1xhPP7zq0%5B9yB$c-P?^$)-tQNK585QNHm z%p9{6_|6|IT`d|aTE#p#&;gdVLyGT{P?s>@p(REANP_R62nug{Y-pAE5mV|a)TL%& zc>6?6%g}-t(VotkK^U6rki;_6C5!67BVf1)&mO=hhU8Q8L^9g`rF8PP%1Lq> zpjWaWm*S9NVTf$4P^>r?((KQqeedgyr{4sAjOW`mxN`KJ{FV1jpdsxoW>MxmfI^l6 zNncU?4?7zxQqQPj%Enp((o+kn3T)oZ3Pj}xq7st`F_e82e#T6G(Ve64jHW|8ME;1s z@UlpRk4{obma(V*VKz*~y4iX%DouT;RhcRfIos5cCJ~mO~y$NaS`X|t19Yv`~X14>er zQLf@-wl*BiAvY)k)Y3S8+HPzB&K%}XH5$a|OftlTQP-FaCbc!5kJ)oQYRF4{f!On; zORG4l(1&g;k1KoWmQ(;+MUcj#SirH!DXw7s{ZoKyEO(n9079p{SGmOd+QCSCdgs$H z;{DI1?Qmel*U@(3AX|GO~kK<~L=L(?z){adi?SwYXFQ>1vTganUzq z%#401H>fm`{r_6p%yzE8wQkT0);k5n3;;U=DANQuj{L;Jm<=bnT0OnOm@=;;?*r&N zXu@6J8i)$g*zdE}+xP9q7ZnjwiEstU^6KkU%a2r=G?dpun;Lw4O5EqYsY5YLfQ?PA zFnwJSUJjxTn@PHvX(2DBR9S~V(z^j=|CAF^WFR`pA0KO;0M%)T5~`toPdPAu=>7mJce+3D&-FgX=a6p1fk+`Q(z;BWo|tc9V#|Vc-adoQ_)G& zmWr|`l)a01MAV<(k(sV19aA{v_vk7m?twU`4j}!uOpDn$A`j|;aP}2W8Kk2wg)KPE z>WQ-tVW~Fg9 zA~?H>)C7iOR*zbBdLqit$YzM|5#=5kUHER>K!O;P1Jc(J8(XM{tggWIN)HbWn@DMF z!6$UWR$v;?>?rIhEbk0$Vkug5ELDn%IXTF87FVm5$;YF($O~ZWO^n5;#Qy$(_7IT+ ziPq3!YVKt@jU@r49I6$F82lgKrz2&?pkvl^R`nO9djyfJiYd{kQn_K{n)+Z2)DMbZ zXWN+mt#k5Dz)D{8i&H7=s|JeSHr2LM@0X-EzrD>~w>XJDZdzm)CVWSxpJ5gLh`F$F zD2E8K0s7Ea&xXMp2H75d4Fbb@{Ej)I5%vM!+8q_0kBzB1tLHSM=_v$}$t`1Ur#+_s z%w0mwOK(;?xIKiLXC4hXfQ$+YOx(uWe(Yi=h&$Br!AeX%;o4&|vwBKJGYD&k@H3m#k9a8lznwkbU4|_zkifC205vnYv|{6!Qc9fs51Ty7eYd#1*_2>3 zOL5wGJj{>JLTq>@%lo%CT`~zO;V~|tST}-Yme{{-p1K&c7FadB)`FeXg$GYtmx@aI zYDw6>e*}a`Bleeak4o1;m{f2G>wpP~Zrb?;D8(I)A{1>9M6;lg%S~}3=91VfSFMy% zu#BZbL1vZ!j7Ty6IfaiYXu`TnuDFPv|2}~q#zAOJ10f#@QDs{sW6Maf3YroFCzHug z{LI!;=LlNgI^Ee?Ekgi+xBI98bbR5KY|mYf#L=~l2f7}ArpS^!M!hZn$JjG;Kl*Yw*nY}U$Av*@B^WI%{o*M5KC}kkpe=V*7XJ7zz}l6{KpQ0 zM9$Wpv2<@%+_m5d?%1%UOydR1CdvbsMS7C-d@k0Z#xaG{4j$-m;De5uB#uD(on`+* zgyG3Up-!QduH(gwNH#q9rKHb+o<%LwG>=6sQBeY&sC* z^HAmB>O~M;pA75hbyCQCTK@~MEN)&J!pz2G!mcpK4agFf7gKk`k{h=}jOB!@I+=_FW@_h|e;C-kt zuy=J1;K2_7q1A)Me9oy+(0wgyRtkLQqE*BiuUOKI7hxE>?&W|W^ioQJfV-2Rc6DhW zw5*<#eRLDbNo-t1E9+%*iq)#LVlmlAUP$cf24302UQM^a``A zG*c_P)((v9uGjDT9Q!|RgQSS#s5vwCEyb@E2mU1=kUS8Ld0t&bBb=4uJ*xPjKRW|8 z%p-uiE1=L77}lVfy``?b?U%^T`(9x|l1X*ggwvft0Gym+aAf5f;A-}4(+74*#27~v znTm{LA&p7PRHfx6$qZydmnRn>(23lCrI4kP1NUS5?pkB>Jo?xx%U2SDM^*m~QsGiJf_ zT#VLXNDZC~3&wA4?1n-p2`Kv4RV?`R*wAPaQBNF5&p^ZqB87&fb<>i@Vo6-G89rga zT)y4ZPYQ$1Bkq2rqVKG@PLU%dX&A50%=WOSL_A{=9L^p@S4i?s4@!y6SEV0FUONYMhsVW% z87TU{`vUJ4&lxo*_1yU#uyj#4OQXsdWpUIpDD(hRRbgKNZS`M;B7j5>&>VFP$a0r& z$KtbLwa(e3DTa$GpOK%*qzT4(vi*i+zq^ecM2{Fh$l&+st) z_uEwKOTn=Fd7DL4RbR9$VrB8?FgRadY8RW@aGFG@*Uwcs@dE%q^ZRDY`FDiMf539K$_*)&Cf;G(NIoYwaky5{g`pIRo%^z1h>~Ebq=#Tjz{xpX&@Bp3tLNnbvKp* z2_Ja@rn=T#iIDyV>UNX{mH2x3L&vCm)jB)Z%xo+uR%K|by;d}*bq+meb$k?4kqAh< zuCUe#6u4i4#74;@rqRq;gEbHNMsDc^1^;^M?p6rygS|GGRFJkLDid+X|6GZZ@UZ?g z*>@$g$TdBYsy`zLB!=P;iCQ!H+Mu{g+h5nKiAA)H67NTKD&180;AgcPywhd~*h zrPwRE%`;3_SDi@0X#%D0kS)ZSb_Sjm1_L#y<)&r07Hxby;v=N*GEGIDzy!$$t&7uW zVRLfb3rP+Hzz2OtF(@}?Q02U?4A>unFuC`P4RYQ}PZtjk=X3|a|@}q`> z*;_iDmi8L5)-8N{^zy=hpQ@~ zOQ_aObB@9Ue%6Vrpa3NI7lnFiiWZZZ;tX_K;&etAle-lehgr_DwBXXvKh+7D;?(?k zi0+vT@Zlssh`PSZ9EQ=fvA*0KJ(j2VY6d`oiJMH)JjCcqpUm&)A1?z`eVjf`5kX6x za;FL=+PI=`9QY^t>3p5tfguMr!qfd+pL%Q2lq+74sxfa7+7Xn>d&*-_mA?vLumSu%!aIWm?&1;CsFr@1s6^lH=lAp80AiI* z;3|mR2SI>pZ2aKU(CqWnyFT!>|KxJ=8mA8dCHFK}5RjdKjF?fMXFy@taP3b~LmeIc z1D=vyI#R+HcU2ObU%}m{2%cZiwAZ+xpNOmC&UIvj0ZM#`0b!|Dn9k9167CdkizkRT zeI?@m5N1!S^zJn-UD7&caE@Nki98S>i`O}+E|?;*$}ynUU;Y7Vpt}L&3bLU!h9dKp z0Nst7BlYaogIbP#QmHd@21jF4E^oN}QXrOfD#ne1y`h?iEW3SVsjt4#p<8<|kXLgU zz!m6ue*ceKzdyYBXqULhxLDYCAl`~ZgSwW5L$>b?iny6}I`cr!&4GSP#U$4*kT{(^ zdS;G~eVN^>_BdCLpdKB{%N}-FGz=);2oL}1k}&III0Qe&WvyCNUHb=GF%r5iazDo9 z-x?Ck4**a{H&_&7rN%>B8|jRurbxK*JGiD}75o1t&yZ)*-g7V%XiA%zGy9&4=iW*8 z3=)(Q8VUy~)H0$vQLmawC@&{z514-a<%5F}Jo-d@s=586J+c|u?K7MMXGu%xFWn4? zSOO`0r&k=oa&)8Z%A!x0b++!KR>W*gVz>L!Y8jeIi>A%PADdYxv(A~jmu43GecM!< z=8mv#_eFMa`hWgT;}}+%t@EGVo%a9LeB@C3fKbZjXKOxvp!~kzqd^C|0OZ}G_j_&w zQ6Rz@DILBucUw^bp3cnSIAX4%#AWmtV-qC5)oa%dJ>MuhfrBC$709O<+1#z7rbxOM z)U0KI;b%yiI++{~j7zV`*C+KQMwBD}ss?GXL-e0FST`7Oe|2o%@3>I0`m}4ar+Bo+ z^si4Ru$a69wWpgEUdPt$XwKiXSpwUUKXi*9j7OeybG!IH3c;8ywb#gI-p(l#NmcWN z0f5$J(9E`@A3bDFiw_S9U6z*@0r^vEl$QLH11$wWb}gy{n2zCCQQEqaMgkLWhH_c| zYj|q(D8Q&$<{mk~9u1o<=Ij%yw*u+ET5dHFmQV!)xGqZ|{^~=DZ67T5OGED%8*UHo zk+pI5N>|47>7JD!vEVzB>C{iDLHE@+u# zm$R*ssL8M*NLn{M)JhQBAjvetx3i_UCB?lLOcY|B8e=kVmk};$N2C;Ye1_dCnGLd= z@da7i*3hqlMDYa>M7=W!WqAmYO526`Hc>-&^Z|BU#kVlpdF(`skhwnxRu2k?#v!D# zH_cC2T|#y6f{b-mF@er>K`hUmpBOC#)`eG{gj#@?697+r3l)m;7>Qw#HBC3uj1Q6D z-G>6{!ZG*x)weDUQB*bLj+p1d=zX@0lb1TSb2{6IShCnA`d_@#^L^3+PeG zPrY(HpVI7Z3XKnSYvoyXPoDbc!Jr@o@WdtT)WDkz_~3rqMMb!B^Mhk<4u@-0>@D0EoA*5M7+e_f$r;orms> z4UGTRLg^otzcqEPUvb;pZtZGev~!#CBk*8#>UNy}1a{yO@tlUdu_01jx`qDMslc#4 zoVcR$Jy1mxX6gI5qvr4YDaM7cU7t9_ZgsIUJqMotG?V>RH;>G(t(^MX1L20ZTH8tx zrqzjDP<5T4v9KHLi9>gnZ15K_z0&wjtW*hhGEEJB;U3ndER2uEMwl@fO6>YW9{*7_ z+Q$_YAbRUw@Y2of&cPGivkBtgeOV`a&%mn_&(So?4X*s5QBbW+!H6Y_s2VkqeD25w z>l4ne&x@`8%6Tm0I@@Djp~DGoXG<`p1RvfV?53-zeHpfVLU2pl^n>64IA#_IhWRaV zUJ9&3u!S%AXreXU2W|qdmJ@r!qOaBv&%Qwh``o+sI7;beWJI4x`)!Una*g2kX){{A zK-%Noag!p_LYjnv)#7ko8SrL>7g@897@Uo~l!#)1xMgt? zxeYWR>*U~?W8@v(;BP-URyQY@Xf!rB5#rRgy_aur+y%=T!_$=L_16_YOQI<1t7bf+ z6ZVv}_<+zvS<_(~n$>VJ)rMWbbtyWCmkb)j-qZ8KLbNEYAcv8aUer7j@h0bk%_0k& zo)5Y`(r9LBX0N1~!1~}>9YsTvVC11nP3>R*sV_=M(+Z_N9Npa791k#!&ujJyg6Fa> zQ}MkJ1zqkm-VKFlG5d!T9b$iMlA&pxL_?r!7bbT`8|!!XF7V@7JH`YT_L6Hf6(!7? z(XV2WQMO6uYAQx6Cu8gt#^_w%enbsY?SOjRbW_*Jd^5uCf}1G$et+sjf$>Tlqbb-O z3B*Jk`f5#QLHmorKu2^#o=_+!GF`68gHv3H_jq*Vo_Q}7If}NP#pC#3t3E!e#BCew zu~e=dm8K~~6rE*r={EKXN)JJGSE*bksj-)*8l4G51Duo!Sjp0vbGue(CguCbRW4XX z=g$ilo-Woa0_MIc=Ox*S5|@|Ys;jHHnV~aZNWI#`)%DfrAK1svs*q;u>AMJb39&bx zVBbQ~5W%5-l;g!?&rC}sQVpxnDttOUAd0+NAbN)A(dnAC21Jnl*rf|<6CfS1iUiR& zqiw22oA;Lf%>Jw33C9PLa2lx5Gfph~XY%;dC;Nl=GnpvbvI9Wm`#{;%tC+{i5ID0w zg!k8_aCb1Z!6n@%8-iH_tcV!{xc#!(?1(_B^G3xo8OLKwhjs?d$D*qla5=Epm#QQ4 zz{J~}5g;%hc8If={n^#VG&On20sL+tw+|5FwTyCg*Ym6;7qrJkcwUNVA}zQmC z*ebHNPh0~!nzHNZ*{Sfq_({#|i^~H(NFa=c75GwlQY-#n2$@ZU` z-fBIEpb^Hf16@JnC&mRK5}9qR5aCP)HU{_lXE1SUL{f_|$kipb_X3_GR$ru*4<2&e zW;h=5p(mUx9JGuLeyK`ghETAp31Fu3fMSqpPzX>x&hQ-yRt_P*i{ z#iF(Z$#)XQe|&%ZI~=bOYSekABcVg)?H82&j(}Q_^Q%u`m_7L^-_-q1jpm4iQAq+1 zp}f9oN7fjAg#F?{y8xpf>Q68hS;#u7n|)q9AT!g09|J)ZPF7=zogp8pHN|Iw+t(-z zblv3G&$Iw&T~|6!4muwFwtXFJJ^a{n;dLz}$o;e7%{%OaP4mT|#?7b#QAqPN%JK)e z!#w-vD(DV#F{EqrmuLo3^1bgKut+orDKJi)h0(GXQbw_!!BC_aeAKTi#Y=?$R)YF3 zevJ7@Hq8Dh__%I1OBX)y7rmU5J5$8E?3Mf|#~S!f!KveeGZ!?)jF2?0yt3ozg$x^} zzzBrDx%rNJekc^@s^NeZFs?fT17x2gCe32#v!VP_Lc;p9mr|e~4pgNN~&W?Q@rXkSl}j2@Ri)zg%Btl!V*D259Gb7rFp8VqQ9@ z#B(Papd*U2`JHwefH#cmRm=E}zXwoS;bW#zsY0buk)meds^uoAeBj*&cjs#VpGb#PFXcU_tWc8q^j%sholK^}LoSq93WQw2# z1Dt;!DBXGfWBmL%pbu8Y#AGEXan`WPl+VV&H$$7S4(wylIz|Vu{`&MvcY++40Rn z*RNkWeD!DRdbPL~mJ{dMUT}nHf*mYVX-2F8Y|Ir~(sO+Ai&7CF8#)WlP`K`^pw*(E zW%Ao3pb+0Rlx;L`DEKWmLXMlM+w8zZS_@r+4--~e8JP~Aw^Jyg6dui{Q)}1ogZJj0 zkAT|&)AJ4&>Cv5`nLmrYCto@sHdoc!7+Iq5*#92L)i<-{2LVUCf4e7#K6we!?iH6t z@5|B&Ny^8Z?QHKUnbC312RYTfoZgK?ghsr=!-}XSCK04J34>SzsX?&sBCGC`tt|-q z3*P%|+w_)vQ7ehhR;LuBdEpy{o)DL*Ce>2Ld=L#u>MOXjz*4;91*%{BOz!_zXZ%?O z#&F3Ant=r>0^cl~J#;Yy-wJ}PUnuvnB)JkD1L95lo>R43W^QjgUm2EczuFWG6n_7iaqRdSO0R{6|AD-TJM970htW+Bb z5^GfP&+164P~vO6Nhjb;Lxem$_N4E>Bm!WJ%gIY6@q|9b;B{m5fg8blNIpOwGNHM5 zgSRcMmMyW~IVj8rBcX8m!wJgheHJ>oRs+AynrT6p1F(Z~6*LNqGF(Ao+kzMM9&pGIIKPk(A+UcQ{Z4sU67iCbazO~F z{;79`4{vXi)S+$o%+VS1-ive?PKpz&Q0|h7E#H|}v3)wh)TC!~VfZuPbXEgcoD0GX z4Cqhy)B-v5g`kFFzE6s5?IZYSpshv6Zc~g7wf2%P%zowPl`Ge-vpTO3D_kVxr6x*@ zTuhop&h#!09Il;?jb_-bnk_M^*mkc+A4##~8Ss$vZEcc5xV(8ml*&Z40Z>pCndMH_ z^r!Zbk7#TUikMI=q1eyOW#1rAv;R70tmlFqvF-$nMdbE?6(8V@pCk2%MdDuBHLXmi zGghVPH|X8-3MP*Ti!f?Dt*6E$L4Aj|;1or@jakyqB#C~|hN7qVn%aLw_CED4&QSs_ zrZj*cG6*yi5w`=@q5J&x(523m)F_Bw?(-)T}% zuUjuGn(E1=Lg4--h20zvD#fIeXs7$oVzxQHE64?ju7wy0O-&668 zc7Aarr!s)4s-Lx2QiGtZ;KGn{QJBj_&~}=#+lOGqweWasc}WJt(XK$tyxg8RHcqc-*82Wd{20Ap50&B@@Ci zYujRn&o(9A$vW&nY+SMtzjy@!CHZ_?Sc*nhZVcu9%Gx8x5?I~ik*MQR10l?y(naFt z#m?X9f_1Y>cYfk!yMUw^En_%&1fUFH{Bhxwy$!to^yzof6#^Z?w=l4Xbm>D?&m+if zw4E9AN98-C>ZUmjtm3A+zA|(~6!B|TkI{$;YNNaJ%p>*S6CJ9CxRUjc>Nuns*!fVV zygYQG!HB#=)PY$fY9g&r$LvtZyImSnJ3aaSY&A%MT`^p7C@P}hZ-Pa33VrHlEVU>M zp9%kfy8XA&Tp=zgC;_nu310-a4uMdkB*cI}ag@Fa!}OhY@0P0MEv=?_m5Dw|qyU6*ly3y|xwq2;k{%o8#xMXCfAm8m zaN%VA)`s@xtYS@1oD_#8;C}Cutx}+b3xD9@+s~dK5~QKo;r=2Gdfz*#d9UQCO%v@OKvvpGRM9DQAx?D+fox9y=>= zrr=4z-B1*}1`PneuuY4p^-p`)0T?17{+~RL6x+b_T-JIM2{kftgE=KE!HBa5oe_;O zNzPt0B#T9>z z(lUL6Xt1|~TiZFfR+mI59M`#$%$*3G`HQaI7B^xn;W$_cvb%)@SG;gC)i|1w!=gvZ zTg+(dWmt|fkh^MX+FkxuxfQrqCA&9d6JmqiIx$H=O3=A#oB5eBGsXy7jdNn zzqjaqf=@cL6pH}39Kh^l*vNPjJbu+Tlv;gteCM8%i>rj96szyzK28tQ>-xNZwJ}ehGykJ-Hz#i1eWcXDFco>bSH(VAeY+n*yBP9qA>kakEVp(rKDVP#ITl&Gm8z~`wJ;6QR)tsi zQj|FgU&uHuwiZLt1MUzB69U#p1tG(txlZ~OZX-R>%C4ZZyZf+@;Us5M6;nPseUCC~0A80bNp%qw>6 zeA#a7I z7O7B*0cIzkL@yg_p29&v|J@)s|K#;GnDgl}NOGRD9$rMMsv-!81-`u$=t+ZEc@NL= z_&iG_N zJ&cS)HaF+`zNZQI0xcAAUg7^(I>+!h-{Gv^26|HFiGG^{w494N5jZ}~-`u;OWQ6^!VI}IC;_KM1F4eyIzrxH?HrA=xON;3XoP-NFU~PPw0)sd37Hh^pe;3Po zLM|^+(t0A2E2)eoAZVWK&o{bm)0u8PJ{as6n)wyU+jJE-C(m&^JGhwZ)QI`Mb^Nqo zl{Y4VAWAGsg*;gXk$$llhOp(?{U*+>H>$XdgZZg)uoR)x%d@^32rcQPtbYm@D}-Pv%;yc zGc*D^17eU}G~X^$xm%lD?W^?rQ7KdGDuHZi3IgcB#9n7Jo=A$c*62dL2ms$v2T z!TDg4uRn_<#kvo&J7-jxB-kUUDgetVv`)z>y)E z#z`42HF@=SW}dZJ>nT>T%MSA)d78OGO4W;ep0^3*&=Dn-Ey!Mc#`6YRpT_K5>W zoN97?@=59o95iT6$#qUmu2yg!3-(b&&-ECaQ*##>cSe_s|93dMJhp_{Zul8xge}P4 zs5T(HqvLRrtxR59wbbt{)=F(Famxgw>M0g0)S)R9i!5&q6wvgvL+@~;M4^?G!#j4r zj1A$gOgpn3R7m`cKjj7X90Rh(fZ8UjEO~wPS{m@k!Ggsf>rxUoz z$%>wjeCixxDa2#zvwL%zd71XiX7%9bATET49&I*x{(Jc_lU`DN)J=3VB6h?xqkoi@0VAE=F-Q%3we0q=`r zuDZs~>KQJj8YU7RT?9+-<+-(1x)c5<6kVS&OclO|v&d~DZn;h3Ji+xOmfwC9J*p@@ zh%kCa1|+sm7xIy@b<4&a5)1DUG3H0eV-i^X-U56>P9Kdi%FM@kTCMxV!2{ z*T_q2tnOg?FVnt=wl(wob0HfitKo{wO^F^#z+1^T#R!jt%^}<#QUeae^cl5ZPnciv zX*I0D*tWBJwn?=7N{urat(DhO!+dpG6>f`qmdNfjVQrmG@9%l_c$ECU5T6%_cb-Tq z(_d|tc8FW8gal|BxPiqyz<(I2euE_?M+u{`P6Zi#@=B>=k4#|$ps4L4k$hAVX>Ewr zaXYyv))TQSwCs=BiKVF*vfp^jY%6=fJt=;=isn(Wm6YZE{Q4rN)myz0DmH-ixmAHu zic+$S70^yCE`%s|8;?X}-trVxPu^OqwA+V7;L`_;)<;lJTa zR!-mvhTAlx=0<}r`z91O4;Wq7aZT58YHK2$lUvbgm;(VUIswU(AEP5ZPVQk|q6#dh z3#qjb#We3>Z^tGGane{BKYlCilcm!Z6t`oNrc4%7^FH*6gFAiXUFZ{D5wfaL{#7|#X&JO6}6MlCnWgL@ox?yR~|9(@)(*z;C6q{+{q9fq5e^ukc( z>(6&_fj*H1AOw{KXeX#`RHTPF>V}AQ~R3M?vIN8GTaEUWzFx)FQIb9TiUefji-vt?&md z7(CF0cH&0$xnsfPJm&7Em4{Ll_o?_`*i@m5< zUIa(JUi9%t50>jZAiHqDC~bdN=?L4fbT+%U7M|VNGcjX(__=$mKIc=~(I=K}jn5Q- zwgJ5sR*3Xc6(x<3^4&U}&Z;`buPL(6(Uh(se&}XYjLi5Zx#rv2!^>t7(d)D2_F5+% z<)h7uaPx*`XPRjKYcPWenni=F^T?kbKH!emU=aDSr2}rDxSZ;u$wWnZ^rBp9`ni@P z55GeCY0AWZKGaS<*WVR>OZ+IxG$BSkG7%tHi}3WjrLt)LU;p_2qtUmvk{_M?9JfsZ zibfT(f0PQ@VawxP+3nru8_>++DjcTHEM!#BFl|^gq?uk!F#1`4Jn0Jv+!}8Qc9>!{ zakU)$9az+c3-D3;%Ee-*5m1F^iy;_?-*L@eIJ&DyH*R5pf~RmJhyHjTRh+rQ9% zSgM51KR_o}TbL00rZEbg0A9oRB9kWW2=7{6Wv$f;G3kuBBZ(5og8r-6e|RDcS9Bm? z@x=Rp)VAq|9AHWJ+zv0cxdW#d1H3*4VA;vRV6JDm*xdchr2#dWJgKGY-TcMFBh$P9 z=Zof3PCw>rrvOQ09dCpH2X06r#GY*I@K05!BWzmVul7xGBy1!5va2bLlc?*1KRXoR z*Q>0AxpK~;q~G2{qdXOMaDu3uL$K6!^!L2H_*(_>52w%M1I7hF+i|nQ&@h1`HRu*i6v0} z>+!3GDd)bL8)hf&(v5ilTR3Kpz;92kJIcM#7o=49n824=Qwj&Ht(@n7#zPC+hzXgY zTjOV3tr%;uYh+7YRHK1USZx)-k)A!+L4P`Hm)~UcKYD#At=ITk4PP8l2k+1&?0%uT zk}-NfSk)5E%`U0+@}{e!1&^uHiY<_X{(OjN(*zr`JK z{(wgstZrXo6*b>kIQgEIsa+KN*3AZ8hO2}9n!=}4|Gj?pG({du-3@5+2{LGYwtlR- zk_TdvMH1It08mnMA(c4d25H~bV7{(hy7WhhVM5{T@{GTx5scL< zR1=v@T=Wl`m@C~D#Yl29CW)B_SFExK?6HbtYC zl?AH(E-9xS%ju8Y=T2gq6Wcq{XDE2vlXkdAq7SO-9k*|N5ev5Kj@NMaEOAQco{}9N zk|?gjp-00j`Fhb$D1N`vrZQ4V1caB62rHye$HHa803>L@U#QOF{ByKiv~ph|rre5# zUjU|RIIJT7^62f=;wR+fu@p=Zs}irJWN&(ZE}&5^()_RSNkMrPS-}(kaW;k^f4JQ) z>_m-MMGYZ?6kuqF^V03)x)vv^^0UjOQtO(!K%3{HFi9!*`arqToJh9~k|4#i&0u8G zL0>_AtzoB$fn#Q8EZcC?#>zQ$In5uX$VLj}AHt#gY*CM4dCDVr4(B!fUP52*BwpopZEXdA$a>@{)keUJpVL|6f z@XT`iIGc_2Stt#r*_3f0j}AgiFkKt4II_D8xPl4!F$u(7HZu<#ozi6P$LipUc5_nR zD%cMS`jx^VO;gJLmp&_!V<41B{PV)rd;*`S$|8{vY^-g4!{CkxFFcBgjq|wy(;M_V z8U;GPE0->WG8A36FaB%VDu>wdvrJzFmKy%y+&4G)l^{)ajJi(-TqGEXFLe$L@flT4 zPnmn*DGKC|kR|P?%YT2ucyhp+MX`T{;O;HlWVB8JFKxUXD4z%LBj0z6ORu=;15`G& zcX>G!zn^$=`3B=h?XZzQUlABa`oI7*vAcwjhU4XvqCv9uDA}*IdvW18^NcRT<8;0ywtBe%j za|yXDW+${B2A~U-*(}6c0!yXA=9Jm&;b43m>p9`Z)2_Nv4Cy65WmUs6fN^GKL zpkH7S_kVF+UpM00S_EPsGypN-GfBTVvm7gH^TYSCB$gWvk3})x=ClYL;>!B*@46Z0 zrQD^n3@+lC@$%uQ%XG1ds7>AKH~9PDB>yU5;|CF&^X%noy~(Z_M< z?mgu`7Q93Lf9?FcVf5D&n0?9AuyGhPqY!E5Dcey>LwKoDG#v8JfM(`G%(IZNiv@vF zGY5uq0@tBH(8pMW0NmqZmmj|J-jI9M@QY+f z9L4>F#pmAaqlsjzI-g-fhN0#F%G$&s$56-E=dn?}6#zEk>bWV$8CwgQN_Ah@*DwB1f(dS}i8*DHF0(H5i+HbTI8Oj=YCMO@ za9D)ow-OdUr{b=EttHEU_hDqwp0YxlBV-65jdxEqC>#TN<{Q!9$BEx{)449W@M-{@wzEYu7C8rp+>w4)8bXcjPYpXlyhM+=!A-R>X*@Q1U%14S93!9HQ?jA{G z$;EpTKGsP1e=hgY;~94uNdj4Gwl2l4@{x!;XL4s^qeA~Imo4ksY;?qBPnZMmLwK%o zR!|9ovcjuqD~yW(iz34nzi^FJq@3cC7L?CzzMo? z<~D|x3Vi)PG2`FeW$mLe*I(YFS1v{f`c20i)`XCz6^k`Y=--XT-r)6jl~yK&3X*zS z3+{|r$!aaR)JnxVEej=YsSDYacrH!+bX#qcS=^^E9~aO!{@$^+6`g4v!x{>=w({)) z)7}c81k%1;-5@77;7`OqezwJl9;WC*@3z921js#3gg|W*Hk|6d9{bgOB-;yD^N`Ig z=n>-44ehdTir^Q^_`F3LT>K(rQNjTgVvBY3p^21qeEQ=>7qNtc``w%xCazOmOPT5(r2q?F}cvDkwZ5%-L*WalH11_UiL3Ph2`{ja2GtY(wKaiK|NN)WqMEy(#<31 z>wzxN6-{m8>t=2?HKmJr^4{F{1|zYxA-UYR4u@oE^YBEeFk@NAKk>ZtPlwJ4ZfKGs zex-kL&%zX?E&5-K3eiU%swdvTg1#>~aXH|Xb;l%)l)^VpK_P$e_04-Xd%QSXw^^cCASfLQ@7N}9Ya425XVlTG9!fk6Y9$hEdHNcBxg?sg|uy`Iqs%U9Wo4 zwsSOj@k3h|L5hJ5zFj2Q%ZOh;9w#KzKvw`D`}=H>13uYRV*9eY?E@Cd!EUIH>AJIGJI*xYLngc+6{ah#urLTLp zVrWTh-Oc^|nBuQv8E5s0$^sUW9HDwPpoX^o>qzj3@PCB9m(w}$8{|0@wx88Vp_v^g zvbWPEePnyymW#9;&fb$PBPR%|G^!+mB0tg`Y6;w3Xyis8FH76~aF;Qg0e&+w>KaE; zdyWZH?$|=xkCl-@Uid1Rk<12-_22JkStL=PGAn4OPpZ!r;eHxc;3FA(|1*BO_OC=!GRD!v?rbdXw6QCxnq*;=t6?h__eNn>xeCqjMjMEL_-Sz&r* zYeg<}?&yR+&M~g`gmv^$eik2k!~FHil++eH*8^Bq0C?f%;{HaZ(@$Ej=5AkKKlpHH zTqP>xQy3wzsCZy&AG$3HgM z(A(^F?k5aXyFfO%>Enseu25FM(eT3Jo;u{Pjz-`9>wp6C&g}_=+#6It*Fe=?o)jsS z4mlb;&Y!D*`~`ic2RNPzIc~W&z2Xu6uhx1#kC?z=WIocfYgL@$zE%=?f<*y<25K(c zi%PIi9`yIjRutY;nE#!ek|&g}++~2=UyO&3Pl26a?L&&xf)J*GY&-u^mEt!INaA9z z$3L6FnKYAJYdzGE7H>OeW0Y8lmy@$B(5h%=gqQ$Z zG){8cW)&(*swUHp;vHOkYh1ZdY})=lA%&Ecs1PxB0?WP+=Z7cw4&M%C42vY5zr^Q9 zKP^@9XmXAqsjHR%)8bDzHvT+4GirE(aut~hKjlq@f(}Q(6)y*XxD8H8XiN@6W5MF# z3RaJrrFo@1Ij^-Grcr)sx$%laJ0A?-n1vwhmFgxIS0^kwjf!=P5<_s4nkDHW@xy01 zZAii>^9O^A#x$7YGtjA#;rkp)8v@io!U6IBO{7f6DCR!ly7T>VyE;w)k|cQ2$%eur zC5rETKc|5B#fHs&n4r_ibI7MBLwbcqj^pdBtLyf0Ls=}%TK0d?E6^Yw7l;K`Lck6@ zRW6$nTr<6DgLz}c{TE+~x|~VtnyVn{f*oKmqq&=@tJB}gJC$bVRRR+?*)aqZLLxGd z+K7|EvQ4KvxER9za>wIx8D*q_ypMaBt{@cTMZH>e&88Ag#Xh-=4CcAxNH=-LN*t3y zI8ZGTJ=rV^pk}=D0-bnsTkp{!qrugrYU>>LVAHrf1%cjU&K!Uip8<|!rwgnP?ODR2 zNDclrJxiRCU{E~Pi>+uyS!1jW;l-bkMXUF%;`N)?&^#)BbxWtCW6Gz6vs~kYGiyum z5AZR2ARQ0Y6z4bVwCL1?@Br4=&lf#B{#n@1N-|V6v{V$%VGfyj@si%|_Ts@O)BWI6 z#bOqODcJ_x$@VB9-~TKmcVKCQ4Bp#H{D+c=gA_X{*2uq&ggPx{b{Mr~YpG!kSRZuo zUU#pb?hJ&5+xsymvHylg|2dcTdy4mes@XVm=Wh+$NH9v8Y@vjvE+9%St2(#yHrzfC ziD3P@v#XG-X%sWPpA+-X|J9Zj1~1G3@K+zXR$jJHm4O#X*e%<{7mE=Rfy0#F0KwbY z**Ie@_+2w8+eR-Lz>@^Mua05EY~e1u`d3GhK9c{C(7x{fR`c@Id)74it4#GrDjNLDH8b1P{#DJ7Z;%?+L%BAA(G)2=g$L)$<(m_NFKIE->+-H51wzY zQGVW`jha-tH7q9!(d9$M5btp)fML;0t(9n*_Ptrz~i&=mpqIs7e5Ond03A&TO(W}US4yL%Ld z>zkYQ!U5yL0iWAh=;XKnj0$g_e*?PP95w>8HyE!8nRqEJfOuiqfCDg3BNIRM9GbxZMh1(^^ zFlMF;kC7qxdN?LiGplwB%tn!-$H!I2)w$~{ad-IFU{`5cDn>E67a$v`KpMB zt)M`GxK9e%T5-KF{uXjFE)YB^^(#8Y)UOda?*;K+Ak?M+QvbFk1lwpWHqL~GnQ(N` z`@$0hU@aU3KdL2fUD>Ff;`#mX1Q;b_nB?#&S0Xv6%onx69I)ssOlxru}XzBfBI^nPhf1NUqLnM#2_m!UHkrLv1uVrbM<`d z@4(cl)lM;pm=3gayqmhwM6%0uj*I;ptweQ)JrIQIdQn#u7~bz0PKyQWoK z@T|5&$Wl{eb1Gza?h?}44kS{6mm+K6kLITbh`W8a>@x~qo((uHL=T~47OF1%=%6xMbqz)KJ<9`}P-N(3mFdyVy8^uU zeT~U-vghXH?0NLqYa!N^=b>a5<6&V77Y;sxb}r4e`$UZ8Zv`#XO#@9$RmnD)sMf#j z3BHpm214(5d|@>fheiAjxtULHO zXVl-HCTPJ3`mbe!N!s}kF=>63H83RrXB7M&@H$f};DCn>m(CdKe|RzB++oyX~YS})GKYh$eOXjRq7}|mJKl0wQoZq|e*kAR=CIn@lW$@JRnfD;y`arBjMb)pIu!Q3Q+bz~CIz7M1t{Ar znJWhUnKpb=74Sa6ADQ#YI)RluqX6Yi1ZC7tJ$nrA|5S?LIJnvTxi>&!Vu7^Q?3J~J zlZf;Ed+hrrlZ#aYhSjD<=b5lr)?aJyfE;0AS`QC?CD~*5rd^Wc6cQI7UacWB zH9Q&pLsNyVHU&UUj{_F)p>ax?J_%YJ8siF@fvUgkn-(Gf>hAu1Besw2**MJcya8Iis0R*8FOWEnv06q{KJs(ZofpRI`^-@ zfbaK^DX6^rYDXez%)Rgl-536OgKbR;^10^{I?BpZP#c4fOh0E5xEd2G;&ik@Uz(on z+rKVQ+n)05(e>;&s_*mbdTGjm8!Q01Uf0({`>k7jh}he8oIX7Z>tkZq4<&(9VK!*h?-!L&o(ShoW9 zS;_gbz$GR|1sB1Bu=AZ3{WcpmUjR2b+yImnCTwmy-&bv;JAC8qMLJi6fBfAhV+$l8 z{i-g@rOVi=UFdEY=kZm{Gi#VHm7yqQylGG%Q7!kjA_XXhC{ZYW)|cO<=J_KMCzqgJzk^HDaWf>K z&dvR%mD<&)v0mIQMyIBAz*F-(H`;ik!CIT$m6za`G)`oK=NS zLF0yQtm|jY|C}p;R@FeXWh9;+t{<=lfJ?*9)laE8#&f(}x-INH zb(SQ|nZcmXlAGuzy{icSKE|$jtpOS6YMp83F@vh{6TtN~?izk{xuMi{ZzWdwpdv;Y zE4SM2$KK(|hGVlt>mTd1FYHX&_lT59sW{iv$m2dBh=En= zmEkGWolF7G`AMu!f)*i&^uqxj24C+}Yljd)yIGdbM;t#ecjn9( zC>@Te8iE5&ldC?fo6KA{_`@%4eO=}hmj!E@2mm5Hkw%kw+%NoiGaz|@*S_re6zCGK zUVvxaMgu^n0Fd+016h?GoiB0%dZVex$vFW)j5;U>W9DJmKz<|`3{o{v#v;nR<`&Xc z<~hX_`fsJC;k+;(zveu8%HUj_0d{75*XG!Z3%u9$QQWavi!*trE}#=R-f+`8>=%<_ zL0aHNKH`;ll1sZ4iimuFZuQm|UlG)PV+PqQL1HtGa22eSFB@@7a30;l1}ohWMqpxl z#G@uNtZO=7g#obwf3tz>%}%zw1Xxw@%y0iPhGP-9NHLm%ul9Jyrd}S+9o=J#WAsWR zzrMX0Yt?P8rlpzI^Bsf<@%U^}InDe7FXht!pjEH|8~)XU9+NlFnDqbsL*QjO#hy@+ z=#PcZ@RJ_`sL*&0$D%u;L?*Cn2t0kbP7zI82den529{xdzPyrNrHKRy)AXuHJKC_3cGXMomsg8CSJp`K-@da{&}eZqoFE|fGP2)q14aO8<( zKe`DfvNT}o$Z`DD_^vC5vN+B2`K3I+M*yzGa~s6B9Xd8nn!7JJq5HJ^^pv4bKKx7Y zM<3hC9Q58e&i-WyO4sh9n8QQ6uZuRw^0kT>EoD+)0cKLszUcPg!JQXh(fOERw|h_X z_xiVu<%J&JkEgzd@8hjxWPO%6;E3ZTUc@J?b9e_Wn(gCP_0zZf(31f2cNtMQdTHEX zc~#5rskDYH+NVQ93%e3+iutnGn0cUj)rB9tZR=edm$A?O#wkA552lYM3eK8`kYzG( zd-EAss8*l#dLBV%0w);`VOZvO*N7>(0t?xgH$-7;( zK;F~`r_}>hF*nw7qigmLh~5H|wq|r4PN;vQI+C02F8LS%RFu97Xcm^Kzv_JoI7#z6(7xy0^swmHcMu+?ZAILCE2;4#~AokHi)4apT8)WAfzwn>Cs*GAUWb5&LaDE4u_bdqC)>eM#cZW?P z$a*pY;#(uaU<7hO(*PKlp3Mw)*N1zLRu3P&=4f3}hXS~Ia#SZOg9xT+yj-*@a?OGPnSLDR?uS9x*>9TK7@3g>;1zk}s<~2URmm;a z%sAn%rJ5F zZt>61j4db)R*=IKJEQWx%yF^*Kj9G%r=8fQ6Owo=4y9L{6bm;R?WWE+{9tDZ~1 z05zNB1jxZbUJ|*g7S_C$P^oxJs;s*{NyBjB{|IwXo%&q#%fyrF9nB*5rZ&9|;q#_N z`fE~4$6P4}pxu1&=g80m#g1_R$`&W9**IUO%e&0U2K553JS>BFBnWF3xlo(dXy}-K ztd5yWPI09H6@bi{IyOss!#_3F#_!~U;RB-&h{FPSuS`+H(Bj+yF}NY`HxbXykAr!o zgU||3E;W}5_@iA4;;of{aHigaS(1#Cc(Ta;TIR7~U0BNS@VykkuR@Dj*`&cylH0bC z!qO0%0U`!08xU%)Hk4-{mdBURgcg$ho1H8~#4v5{))er`W|xoX$o6Sz$sN=33$8#T#^ zdM-h2d5C5tM3K@D-crT)l&`C1rEg{xXEa>~qX+UgpJJ>yUW?s@XORIK^M2GNcqQ8W zT?%#W`#)NBsIBvq=BwNRJ+<>OOrtMO^l{I%rRb*UeSA)cRm9-t;DGM4{ZiZzgBI4z zWxvt5{8mH+1XNMLTQs(0>$$pXgqf4Nmn=``;Eb;!fCK=-2!}rD+6Nz#m;`GeFKx-Y z%GAnzC5;{0_17kelQSf?xchEe*h4U`-f2L4dS5qOUc}m3y3EywD-Ww4OkgckEMPn$ zM+^QlK9DQv_C$K&Yj*>$doi@jhlAj-}-=}Qo=Sn z)jhPp9Lr);=;kGM+0Dgtmg*9O+BYaqvhgp%kgs)8v8Sr!1*p=<>qp#^RXaffkEUNf zy~u(y)Mi~U;%Q_x+Sf-Rakb~Ltw9+@cZtr6`tm8D3a)+1+3IEF%$TMGOBI>ZHu7Tk z>|0}3`+p&sSFU04$gf?p2XU!UfTa9gcnbZsh_@y1WwT+XTX59dF$Lg<;WV?lAM=|lI^kMK@_h$9u z>)EfNF-0#b@+qiDGXu7X{`EZOlcKU^Zd3uf6es8^xP5$N2o^Z5`g*o)fZQ_(5zvSP ze9qw8vC|C@^3|qIWsne~u{2XXqx>26?{wdXs|d=t4QJBk8{0b3rdfYjvr4WH>+`a@ zp8*0Yp9!y|w9?ZNov?U+<=(0k)cf2}q+{sWC}%XGOb}k?h~^PZx1~@IIVdGbYI?05 zCYDF{DLWVI`6%jUF~Li`I80eM1nLm6_w(|<{7iHBIOSvHS)2~fhwTw%EJqjJtVS{d zXMecM09X_0NfAu%6OqD_RDw!P-yCLAtUlL4v>?4v@OY4ZYy-r8u&DbsD0M9CAY(BQqn$K z19(vcz|8>M+u%VLtlR$vBf8#G;5HRHK8SW+HwCcz_0jMTzKB21FMW4Kc;`HL5oyWX zI^WH4@-`Ud!Dq;{Gr6*;`!IIvxrH4A1UXVOq~=iaO|s*}q6P8+FI5F_>pNIAe>rA=J|tY3_X&uR(HGPjl_Q9hzx@0iwCZ3e5a z(+M)xnHP~HpWe@AqFUJsx$dY3aF;)T2Z&Qyf7l?+!F*_RxPf!%v{?9#wm$GUdUG9wqE{)Xdun!&XCWz zx+)T;7z`2es}T6P@MG7d*6Ux&5Nevf^}`O5Q(?nJP8ZNhnz5P{Xktg^#_lTaV2iwe zLacp62?Y@XFs=+aLaQXfv+2ygzFAyXhQfhZa-o(0Hi7c-7zp$`7TWv2*T>{f$0gMg zOL%8PN^lrq4JViu+uNjbIV5>eXDLTZUXdP+QgLQMDi6f0iIEc*VO5${hUeQpZL^-p1S%BJjuVB zU4zrcQO!3lJG612UUcgY-!hf_=z?S%*#c~0shzg1MJ%y)_uGJnU9ZqTqOM;(frd{K zX6Mg*p3?l$^viP=lFH?4Q5>5J8Fn2RBA&>`$c|d|22a?W4(Kb1UH1Ys0pvNK9?oAw za?@%(nhBNlJ64j$C09lWw+}O+B(r(|VQpkMr)?SZy z$7x}k0mdI6wl|)|X~^?LzW2uZyd+Q0g)h4KA>9sc!SSj@xOiUd0soGq5oF^uj)jNhXRcwk-9kL&Y^(u{HP%48UWG&6b}fA z1ZV^Kn*7SgXXkuw-9*~4!V#=lN$W_x;MsV<+9rcIVDe-o#r!-&z6GO1lsPiVU2%v7 z5%<-5)@{i|z)*UMFF6XujbSI}B1X%|@v-~Y3bGo-za&-`5p-l9uqnzeh9d~UuFfoTn7w6 z%`%*z+(qWKTRAtk>5B-lO!%6=DYV03em3N-9+;Io{1P&>JZXBDwcBGk+2GsI_5g}q$-#bd z5RHp+VR&&W`1~wqWy+_Yj1%-8K;`%fjRT~~#nM15L&SDg@Q<`-e@EWA(KYY_`)~J>LGS%_w?UUtM>-O7fSC#* z1_lublp~NJ>5v<0JO$1$T6kF@>I8BJ^NJ#utdN3OOrVv43!=EoB=Z}K*3ODNv~#qh zSd9iWx@9BSPKe>3V~;Y>7_|h52BS0!aJr--?KI+IwxeXNXbNmj%S4i*@*B%Jc`xQ;R)gF$e&3n-x*FYYw zS1R7B`BM9+0=gH)(~ww8RJstdc4-1 zu1`)mi@n%y^!@m;?BKrde44@1A_Q?Ax>{Q3!RtYz0N5iDbW44-R!N2-pD&%g>TJFL zmlz=TP$LTpaF(lBtzf>eS_mD=Vl?XeJbI4C3_TN zLLP!2;vY^{4&Ro!A+x5<%CWj1RaFL5wqLtM$6OPudeHSf77GUtP@HFBywXmCn)`6AO$qOf@6a z0OAxi|Ela;tI^-U<@?dH5IVYGcay%-h#)u19PCUvVMX}8giJIlhRYg_#w3+C1IBU# z2>Wv2ft{dnO>g7DLrhPmIom4qu&BfP!Vw`fXY7w$p@~KZ2`mgBu7>DMIAy)X`ZFg& zHq|Olw@$0TB2*9$&sPvq54Qi?(`Lhm2u^UUnt5(&uC~=8qj&h%%a?B74wXfwl%f(> zA2ywW3iD=<>gxT2`5scEWzh-+VAe;IjR0$=7>(#C2W_Mof5}n z|2^0s$l^|+!&v&o;?&2xea3_MFp>dm6Ce`wU@*j${+7#K{bXL6sFM)6DCr-qh?Iwr zrr-xbTJE2=xli&pRbgIc&j}3X-tBTaLpW34+L1(|Tn&#!${7V7>3VCmhpo5*lxb<7 z1V$!A^n94js=B9uyxT?;O)DVlTa2qmG|mATfG6#`RBNQcY}>GYJJIvEJHV2^go1Sr zX#@68q>Y^<0rUF?4x8-yEB@MxLBz{|n?OL<3yuxKY696?un|a4%vEM4SdJ{s zLq#R$Lc~vRZF49pI{G366>70>dw7cN{*Wzf;-HaNxcx*b;}fTh6Rh{?l+nkkI>@g4 zEMpkJi*xZmrEFw2h|Oy}N8pPh1AH*B8~P6`c2CpXH?1wscsN@a)Lg8-|MogX==dXD zalTc=XcgdiQ;kq-p0+jG6lp}y%cKQCU#H2{0szsX2&O>H9$-u=)_Ydk zZ7a?so%2re+uVw8wkM3z(0egsT9sQ@c1g z5!N&H@6PhYeeS`Oj{C(UuJx4oZz|O&rp8$8hb2e?H2>~S10T#qVxGM>HuV_Z$h zeKRg1F6jG}aH3T=ecSKpxV*2+RjvNpZgf?PRa!6kjeh=mzg76;-j3Ep;EAWbR;&E; z0vOhyiw)tRVi-hirU6gaB2tFX)3IPRsaz~o!ZMm)0-qll{T1=z#vo}bTU0_Tk zpUn*`A&(-vn$fG>S~y^&+xOpv2bcIIMfx5cu;U82i7s zDd6d!D?sGZ=Xb|9Z9Ze)JCQ*N`*ow_Qnw*XGF~}AV*{~0o3Vsg13*T-nm^LCUZ=!d z{;vrM6$M7%V#{cQAniw_L`E`un7u}z#r1as5=OV8Z-EyG;VZo;UCY(53Ymva9UM;M$<)3T6oU{ zX}^h+w+iJ0imRx8tP+X&&(3Kd7|Ev#NK6TbDg*d!*FPFD1HW!6!iXSVBJbUPMV!nN zcfk49{k;B2M@R1a>(qS`o@69XKP~t80^J);r1ru(#YH`}KtUQW3o&JoVltQ9}4$sA* zMucL_lVsowZQurlN^t?!S7VOg*4La({BGgFM-Phx^?5b0{w^50+acp{ zY1h48=)(A9X8|HkqM5kV{%nCVK&?uT2EY5Xw4hrDY=<@i#IIh_pWx9#B%m=udIEU| zESrKkCSZ<`ondNvxN4w^0gXDaC5E_Z&j&RM#>RM2%rU0+(X((k04xI3@De%e6pRFr zyW+SL#u8}Pot29x;<3T};taG>+6vXj+DpoMBh5OwJ9GxtZQ~rZXD&Y;xHdF;5@F46 zn*#RKlK?XRA5B*o6=m0UXK3jLhZ;b-8wm+%BqbD(5|ByPQ9xZExa-^+nep>3HqM9*U2Yg{Y*Wlcfc8H0e1c5&m@i2y8Yh!kuJ-)?HC`u94fO3@iccx-g8`pu!!q9WI(lz%h3Wd`eX3 z)*yGdf?fuT#6nDM+p8BTASM zbv7m#tQZmUy+sT<)t?Ywb~9Pq4JDTl7l#j0ojed2Q*&pbd`K#^Mc^+V5*`tp46DNW zcYQB?Sn&<@PxIy@#RHKF>xS=u_w7<52HG92J;@xK7p z8bXiJ=|9);<>)L}(l3et)ihUM_Gt;T(ECPnC+y-~CTQgH{5>Fr*pna3 zt!&5-1Te$_JO^c28P5;P$*PQsO3zB@o@S{rSky+pKJs4+IfTO{m=msYLiKJBO>mxH z5qhCUZ>gb@H>Gi3Y5ckBs;v6mUp%}^HrhAWt=pGx|HvLaYIRZo+e;FdxDUeLeYCH> zr680T#eI#gNKciL*t49=bo8-1&u{g+ADd%VA6|SrVJ3q>X6>7r2ap1FMH`z@o-UW=h9>KP!#cmD$;KyAA6PT@Xc zVliUr6+cN^*M15Ptm|jzl~MMcUU@i$q_KhUOPoua4_*zD`@w{-Q!jKV!Oq#QyR>jT z+3?@^iz#>dPJywQU;8}wugUsaRkYSzBPV%L<-oeh@H$%d(WS<7k5L~>PJ9(`ivXK zw~LuFcf-fFVJx9Tcx{MGW-)>PW_9O}uK=HCW?_P(^%&`U*8n`I3A(8#nV@X^TNjt9 zvCL<{gVbU6$1Bq1`@jXLtmfVqK#iJ?E{+{dB-pfrMW0HJv1maOI(9CLrU%CM$<#FY z)o8rx=N1S+nffqo@fj%Y@+2X&+Y#OEpK;}`jj<|Lg21ULxFc)afR9%}4|4$O{t;q< z=%cX!J|*{#7Rk6qf!^T$814yl>$kJ0Z(`IDVh>pJzYx1Um>7=E=CBa?b-XsA=MHm( zs}ed*!$8yKqph!^FfD3(-w+WrS{vRxGC&k2iYPgJ!t{3De~;)=5mmnEGt0f27LTT7 z5fZVB`MoQnrF$g_e;8h_%&d77uT|ok_(1pP?>nT<=y*(LQi6bOp52eZcD9?v{9UF6 zrVOoPOmUD}m#s5w7b6d7y&&0De2!>Z&s@gnT3sf=_>XC#aVPe#OY6n~mP(@U{ptRe zBt2=cnGL;G7e!NfT~5`L9`>=q&XH5#6X01{QYko%b}mmJ+})^T-85~(n<;_ z6i9d-mG^Ad;V0u%0@PpQuE}0PDXb8)oi zeN%x^B1gGfpH4}vE?vfG0xT|ErTZUK&J}l$2k?8sdBt7SX zJ`ow;kC%wY`-~?o^@xe5l)hYuF|n5p(LrICZT@HI6b93^}pYSH?Hbs zX5>uEQU$^Nl+en8+SW#9;P=f>2>9*Us+4gfa;#d9N93OzCEX?EKjWqRec&yQ1R4<-%^GS0roe$-P*b94ue=<1R#hd6GXW3< z0N-G_6X$9<6vh=C?9*$@#Rt65b0Kbj_381rX6UVTR2)z{5G@Z%<&2kwVh>Pmq8mTo zVqnQVOiMvaJA@J`P}p}CpJIBIlq$CEcZ}B^Kz}dptMyfHW!E=4|UYNc7K?rN7CWiv({?i{P;ELEOCsDk{l2)zgpLbjr2!&4?p(nUD!!K*Ian?4^;VEHn5c4 ztbhLco8*#I8C|Ac<|{7br-hbw<64Dxq>KO$k)v*AkWe(3sA@c%sz-X(}(ZBs(RS^NPQGBma4 zv(VVd24qLO5N5=+>DM>A8(X-bk&B6qD>mFxc)&J(8{KNQH3N* z`M<@}N7^tA8yOQWt95spxQkkWhAl0u7rvbw_=gtlVH0SCT?R!Q`YG-obQ;)u$jdGB z-aS|7m5BH0Y#n`lXTVQ6O0hTFBQj}!8c@C`f+C8CN{MeM;S@lmfm$I`k73=8iunE{ zU`&ti4%vO0REIHYVkA1D5+gBj#7@jNMKVeG`NC`1@5;_{e}p7#o3G{4pcpIB7Lod~ z$WD~@9;rWE#{SMk_|3QZR^Vqg`^Nilj3^w@8tKE_(0wa_&guLmo{Djmj@_0gc{n5g z%^&{42IHdkrI`VYS*SKGr<Cg~>bHpKcK}gt*1~7$B$8bN6SS*_ zh3k!3ox>uQPn-J7q4Akw6E?~NAD2%PVQy|{_N*YQ*z&_K&5o&usA6-#mjKKqXz3ie zn!D)H&DdBgYk0ImKP45r%F4+htYaYdv1HE84^98Ga`lb?!<-9fEYAN#smg|W{B?4$)W~pz3ShnpFSfdV&!QX1K6*K+7B!dj; zWuDN{LGHItApXPt_q|J%x~ZBLP%#cIqAK!>z|J;di0`NrWW`Zrtam+|v9ME~=vM(+ho<<)a1C%}W|JH+r#P-R*%9BS$IUBnwtkE+c0vc*d|%s>IZh ztO$8eR!B$+CDsgkQ3Q4H{N*A2N+rtBpmyM^9j!VD`YgqCYm2MBRU`9@xF9J-+12X4 zi}(E4Rw*{yk6h+<<>ps(w+%~HFS>ud)s`;kI}c>+{(A=ii6d}7M>%y<$I&ff?V(v-QAsq-Q*9MIMk#5 zY;@6Dx*p?iAUi^MiH(73jqs${mh>=%ZJksHkN*L$uCaRH>X>PYtPL1k8J6IdG*h8eTt?i-2X&i_mmtX|qTG znlC|l1J>!i);b4sa`D#k{#|8QVPR1{7@Jsx*~LVZ9xu?dxyVG~*NCd=YU4#^?Ez$A z#9GHmW2b0Utrz!VJ5LP3g6AmRjziI#l~H@W6p_0&)vmE|J z&_cJ$h|xRD?%}1e@#~9|)R`Bd@1RC{>uway4PZfM_CtyL^&)osLg=!sF-b&xo_DN2goFuGGF$r$XshMDq0A(-F7aZ7 z*AGV0|55kQ&R-wp95eoWumva>pYq0Fh7_}5qKK>^Z@p9VcrsT>0hY&k?Ti8m4w=wW z<+Url%ho4%C|Zan5qN(=_5*4lD^r*b)bTIx^D2-MUauI3DTlobh!A}=ux8D=?rx`9 zww3~W;`4V;S<${{B+t)fmyR{b2kyP-9-qDJxU_w+lIxn?3jAw8^^Tl0 z8SV}6F0xDQJkIXi9Sl*}+?d904AJv6sD+D(jDL&0Pxvp!+1OFL`j%kbpxPL#i798pmo4T8_ogyndp4!Da0`Md5Qn0Ma2! zMbilpdtIM*#~m@de*uS|pEsJ!BAI|MKD?!0RdU9gxw>$5xk4E`?Ni5)nDurQHhgK< z@x>-WzgPrmLnJW8smk}7)X=y)XFnUfTdnsrtYGnNA4EJkS3YVSj{GyJ6P`Kc({$b! z@VtH{EspDUyvX8N2g2woX%`j;05N3DfW1S4dAEirJt6s=wFfCv2JHsLqPDZE!BFg6 zEIrlh#Y&>_Z=|g9;^mPHHM-`dbHW&)NgsR9nAyEjh_##!#TT}qit3bK>1Sq|KzF*$ zd4#6!l{)woWP%?w7<*!7H6rbFrItc9g>}^@+2fHv>GLOzsN02EguW@zH@a{0+vMT< zP8O2dFNBz~U3lPt%j&l;!Z_Wz6ldGW#bolOgMa1}-&8E9`o-<+i2!Ikm()L+OX=OO zH#W^jFJ&KU@ZAh|DjUux!{>CLf`e{TK=;s>;OY3qzF&cUsDDfSpABHbKys)S0J1%- zE^&l~H9x;94VO+^#6+l|Vfo2Sr>_BG_x317!z!Mwyjp)w95ecB$;R@yWTznH={@{> z`|jeYX4Sv?o}$7eYhI?7;-{+kBadP&0;6f{$#t1rr=S8+JE~4Ry7ZsRq-VNMS<+>A z5oYA{x5f6sbS;D9RACs1R`CVD-AfT!xk&m#p-A)ld~=^(X^XLr?!!B#!+c{-9pc;> z03d=_4Qm`sAK$}Qo;+@rNfSH#)HD!V%ylb`*@fyn|6uHfJ_R^*q+gS!{3?^C)c=Fl z4aM31zOZ$Gc6tWwgC#F6ZXq1OOZ1u`;q1g^bp>Gb>3R*1hohq$Bfh$S{2hVwvB*^v z%w>WzBT?3Y@9U&R(5;yN20DNBQ-U|TE9z?uC))ZL+Anr4i@J#R$LQEyH@Jwn%UAa= z`b>E8P&DwGt;auhj;)~*T#8&OD9C_q+81L3A`@AKtmGg%zAgp{TqK3t?P|{A;Hv_G zq`!pwA0%++GAn3;=mNxY&881L!vkPHQ3+1H00|kuYA#s&dThi31{Al1@~>B`23dK| z=X5T;d8A9N9J$-Vw%yC;jsHDqWioq&mR9MlJAQ-;zGCCzVv5@Ry&L>~_4a6n{SGZ+Vw0#Ts)Og&k6*U;PM~5|M#e`(fyGY{lT~) zzItyVcCcM>u>H0?;1(r^@3i2Wm6V9kN?OLu%pSW6|W5<&1ol9$U`t zMqfNaPEqVV=MV;tZb zp{dIwEtpFsjjfnryLwqHE#2eo$)(dzT&l5|L01T&ptJd%&_^yREd2dhTrOoA!i9oH zpkbgSZ)$Qe$^@}|o=v3x>>Vp$&Br;CZZ?Kdph%qr5~+6lQ^ZYO0XV?9~~* zp~#xDEAPf16WKIMU}hrx7@P}{Wxn?*|I_uBP??ck;}IC4*^4wtqo`=+TadH2{Id}H z_<1(^c>Os!o!I7o&zUo=&66S)F3pzJ@xbF<>p50=ox*dU!V{XZd+gZAbF*0BwR7-3 zm7?g@{MH<2jrapo#ScNiQr(|S`WsaYg1?4TP}Q83+nbf_Ed2kCb+M(`Uit*!x8{wg z+1Z?wwmLW{V6-Tblj1Ne&nb{qb+jO+zOcLQtlF~Z;a`40Usz&~5}E;!fiQbUOxe19 zs{do#T!BmK!HeY%?wH52AriymUfOujh!1_~KUlCMUM zThF~ZmZ)M?pYG?;jbB+ClJK;&Fup!A@S6aG2I>m*{UgRfejD;g!AkWk{j|0OH7*YZW?*a!si z2l3?g`0(U2C#m26N#E($N%p*IErlmq%558B^d^heS1P*_KBfx6kM{a+T-%Z)y}VSt z3#WyOr$oU)n<39#Te;4HT#4=XEB2b3E6DY6#L27FIde(OrODB{1Rcu*Na>%0zbd>Y zHcOshn2h{moe(#QKc2&x(y-u${8gmvU5aV0)5f526mcM?d3B`Nw;4vle&bu|EgFfq zC?^2nvuCZ`SsXnUG0Ti^TgR%Up2d703jDP7rTGA9$HoV0aJ-OUi&^_T0J(|l5f?-D z!gPfUW@im}^#)9eZe2by@t#}udw#=qWH*BUsH1&3rq78+LrvbYG@y}ZP$mO$Km~Nu;4pZTw#Zu^W#`PobX6^%AW>@ z^K*&NJk#uFr3l985?fb^qp1Sp)R5Rswu}>SBrzA^`TUkFmu3rNctf1jL)vTbV?b`4 zSv97gQ_9Ny$Mp8EVn&hv8Kxkrc#sa>Q5=^N+RsJRQ` z{~ggSU`Q`f`k_Vp=X%+ZBGQzNGV6{0Bv}E!Ab2HG`3JuJCUjD2mA`7?Nk7go2$WUKZK;C@|NQNHwXTFl zcWtF)wg|bC*UR*?Zz2_+wIA5-%Vw9>-gJsRVWpw3rhCTu#qh9!)hxc}+&Eto1GT`m z)D%$}v0>PQjo27UL6FKUG|c5Xp^AxG)divoEkF~&(t%ltA$jEtvmHEn2VpmG23}G1PHff zar`a@^J|PdVl}K;Ve8^9c)x2w|9lgbI_PV7CeQi4EgqIo?RZPP32t3TS#Vb0okhi$ z(}cwH;Id0BgMWAxbJ7L)yOWlRwq!=3lhJ7^IF4 zFH8^HO9?hq)>Vv-%WLAPY~H$3L&JE(oHW~%2a1527|OX5MW0(S!E)0tzYc#Vi>}S= z@T;bJktBLPo!QK_0&Z=S2=n_X2Q1_tQcg#PwoYAz8Il?03nQNaCJ44i=NVU)(n_nRq(x{!3GENLT?U9kCM@)!-~cqpW-mc80CwIr`Yzt zhU>f37;~l^KKA~8{otG;Q8ofIC{Lke$-?B5!K1TF8<-6+wkSdMu*1}_p~&Ye?*lH;#{5GE-%L!KpfsQYo@G>})ox^TZ%Q$cc| zSm66BIy@Cr`7AMHRCCX*e^2n@rcS&6GTbUt$V1is;44Szl4Gj>#)X_nZN*p^xu}`s zON@?zgZ<|Un|bIzN>7=hP&yf=e{u)A7RB=yx@}ql53FTfiIap19-^XMIt#M;snC9z z+$Xy`0)tbWUazTXd+%gjyJ5YMWq84)f=a1_zbL|rQpXV0m9_8{)x463KZ{`^o}Z{# z=}_s1&W|a%NKwPW`3uK(FW;z4+MH1FdIL@3pc!c_Trh4-!1J>05|8_FKmQ8EEhJLv z6`jb>5B^J2_kFrNMxN6UH#D|i(|`359~aJCehNg#q)4%6Qm=;qPAnPZ6 z&Wnxp%bFKd$E;>5Xj$B1atM>z_0}O**5y8|VJ~f|IB2OjtSKuUnjvdZ$-DV2K(q+6 zAtLG#CIKiTZ(be%q>zv=TH?fPIz%N{^ca9EZfSFA>X;G8-&PcB=^#>$gzv23 z8=^64X+18)y)LO%HmR18@-4{pNs=+oG=GpFM*iY%((|&Qn&g_TwyIFS_GR+1vHr2l zTOOX)rz(e`^3vN0`~3NU#URND2ZxcK1bDwkro*+9I6bBY+L9|RtuF894E1r%N-fl4v@%XcXP13oIe&N;bxyebTe)+zHa1<>JY8zO z-Hf;YZOeP9rAk`5`*nJQ%FoH)dQ3^N8Nr0IpdhU#bZ3zQgaWBH5qtxKQ= zHX-mP=iu$6%d7HD`GRghhuImYiCNmiraq$9h0s+YHKkU=D{(riP=d~a5M@6Vt>=D; zFQ9_$+K{)|>tIZTeUp~J$&22ZJ8wh#T1xR86K$XBsN((C$)I_WO9uEyIa*oiGkmyQ zgp$n!#)l0zNO*dj1I=$S6k-M-NC%`VN6MCv%BnW`LOFN4o3mEbQ0_fBSBlk-ea=Z2 zdxju|pc%lHK4G^yDAID|amk_4v9{P)q<=Q$_{QCBt~0s6yxe1nI)5<#Wte2x@Go+Q z-qW`6LWI-pxWNw>y1I$?@L{7@{r0HNu(kwVbNruYD622X88I>f@5~yMW576M-5|cMS!+_RW>Uw;Jl}e0Z@6BSEocx%8*z z!oBM=&_aR0j;I6wG%K?=DA(%@r9VG zO}sg2{CGKie4|1wc?os|am-h7>YvLU3y!5DJ59^~RO_d+g~ZB=m2k;Gu3tMyzmL2j ztQFf}UOUBUKy4WARLG9>ljF|5foU-FUu0h@$qM|(EoRyT(|=;0P<_3lioNDmNlYtY z;iK(}pgAI`Mf)zphlRr%CjT$hVx^zW(W^V5vxfaCvV^s<)xG6ix&A)v!^Itd!1Hq> zLpii)Q3lM~gC@-pfwvGMYrkcRYT4{a$_j=6E8nY531hmaZ%ya<`phyzTW zjdKABmD%!>FxOf2l_}o^zYq2XvHO~zGMf19tq|+nl4zcu!CtgqrmfX3oxEijAv1ee zO5mA&Dd7}zU)hOR+cx!%2$7~6_(!3<2Oi|7Fr>EnYWr` zb8xVh9{$I2wf!styS?rqX}n;Y|5$WexN1Jum&Bbods;(-lPgM!vxKF;XIow0G$CxM zo9JVbTDQcl7y7$~HK8P?eA+|aC3wZ)Cl)D+&0lQBAclb@1;WTMH)PIQ;^L-jQSubu zS9C7tv-@#spV+4#T(x%3*~8?5RkTR=Mb!lodMt3k(cg)%So_{0GE{(aMRnSJaePXzo_oL9H6u!n zkr&!d93J@K{~d<&rzw6w{|azX{vQ zGVrs6by(M!Gp7G)pT5O#k|9?RxOsd)1c31p_MfEvA?%Nniz{d|meo>z-&~ShuwLLY z)6&<{vfdYH=>p(n4a-a7XI2}rRkn#zsdqmLdlRLEt<*R#{kZ_uQHnIH(|(gMQ-arb zFH!dL#GH8DU`X3U-o5zKr+R>FHGa)}3s2?rrCW#hOUK5?jVi84JQ2gHFV$aW zsqWZAr@;Do-7!L10DNf27ITLTqHbu+*GWGMrNs6S{rgDn=R2KQi@SC!X#(#8C2XWMIm+X`z*8~j7Zgz>8N`rMGz6GD~ z|It3fgR;DA1pdm?WYYxs5%9j5>3{pi)@JCq$#Dr4yjIuw&4o1O&$je3ZI|HoQmd zDkU-jk}z35EQ8nPyy3jctdY9*5`%VNp`j0YDpuF{loz*4w7d{UL-kP$zmt;o?_*tgTI!W3g0%^*{g6mtSvGDr63 z>t>rxL;-sV15c!`){D>}W3C+J@}ja$Ir(^ggL~C_?X0%Y!onjGO4RDQmzF5tD7~y| z9U*NIO(9J~Yfb|ed?0`W*_513$dY4Qbau^Q=pKz74-92>r*nA}dRKv=R%L)spx`!*8#33B^5P z(j687&L|%^<9$hfMpwQkda5<@l+rsi(LNsIzETLL?7}%JbWG-{jfs1jLH*%?TR{q= zVN^FYPIo;)k;ATfTk5l~hO3;UOK zm>@Gz4__XUJO=4aydMds>2B9EQOwc6+VXnHd$;%V5}vIeF57I3jU!X=51l zla17FV~<{)*eJbirEy6T5bXIr?}JBi=R&qdvuF2AD{6MBR#KL^^UJxk!?eTpay|EE z_L%t{A$24?+>j^dqHt;8eQ&!lCooGN@z>g##rvar2MX!crw%Pjsw&)Q)ZH$AUe8!O z84^nLcnnGQ&S$|`(S4#bF4WISYGdBXOJgY2)8$B$#-aQLFC{JSrbUl$`^sh%c-w&! zBlk^3H(n_#W}Nirmz$Yq+6prB*wFc63<2boh%^J)C$*BfH?D^`1<2%sgC+AbpO=x? z&CiE8a8(dF=H;_q5qwI<5v=&&$i}mUnRSHt@D}1dJb3$1EQ$89UXtVQRH^MuS3l-P zH|xxCPl8Iwhq<-@vXuv8L680TjE328ZBx0TLjnVxV=3`Ins2h&6U?{0Mdl=mk#BhT zBN!NttnlG^3`!s60_6iU?qk9J3RzgB*)xR5SGCS|Ou7zZ_z>|=*d?`ZM2&%f*6SFkl!EJR2`FM~5xjJS*fIetxV*v7S#U-t%jHD>yw zu8HMp(DFgNY_q?7J&e^C0=S|Ub6TMMXNO}cJWtbm@u@!P4;d^4qfz3=%3yeaX(yp6 zR11mV%48(&dm`Q?Vj#PSq@$~V6annxWTtL*w?;hn&786GH$p;Mp#FXHq_X6F+aE}P z`AxwkC-)r_Q7@5Av_XQFDd{(0`GRi8D()SG9cpc%%d`A;ntZgqFb6j&m@l!wHvR{L zP({Q~cJm8KD8>Z1CH5ui5sBg0(u|fu@f#Km($Nov=NH(REJ4#9$p=)>hHEWA3(eDG zYXs=8^iL>xf{NTHQm&29)kgV;_3tZL)d`EI9YsT00&2e1)HD>&-zRPtQ0Wd$Wp2Gl z-uvAf{FH~kj3P4Vwr$l3wk4QxsWE&s5t4g-g$KA*T&OGSIX7cVz{O0geN#=f>Y=^@ zvHm9I$Btz-<;O2OiAoh61`85Bc!*Umi)QA6QD2_!E$%M zx%BKE&P<@CvtHSDpYo&<^1XGnv>eWPNm~lbUN|1!r00pu;dV3JR!eBz0bd)Zwl~Qd zg&PHwex+Jd>Eq69|C7>6Kz-!~Nih7t8w`%c$>Pq=qi97vQ-}4{k zlP%vP;XbuubvPr5e4jNXLxDJ{l<) zf{rxap~VX?QuWasjW|)jykryx$za_oeMr#yv5ZaAVSlxuGB3cFfG}I7Ub$AV!{o4E z{0)gTXf)d&s3e-BynYuDW#mXBh!4WC#FJ;Ud}cXlY5B8nCfcE-pyZ&Wq@+e-N8bNW zY=7GTjv-kEThC7S%ci!s4-dZ}suwbdaJri>ZW1=JA%Uh=58D9xZEzc;bqFa2rp2_WEz zQw2{r9H0QE>g_$A+NvM@boIZ^q2#5el6mBJrb$*ry7d#}B@8v5O+G0)2Xiw4jBbLj zf5Wp!-X1Fl`zVdkDBOMcEu0~ki<;r+q=`WciU|ga>J<(=X`@)m+2?94@>|{$#MFuA z1BwT`RqF8o-!Sq6d`YNsRn!u>$j^TpNR{!aLZ`MqD7Mr_xAYl1-;sO9Ld)N=xT9L) zh05MwuAJUGS0T5GyF2`xn6Ih8StM;3D&==7`S^BMO}D0eo_vjcjk<~SPz@&TFJ-u< zU}15*D*jY;9Wrn%1&c(H*d9(#f$J+FE;4{p!M`9#I4*l5*VQ@cD(5%@%AjC!oqQM~ z!2eP!Yx0oWh?ajtV#2}b`2~QwAxEu=S_U+XiHier$E);U4MO?t8EqI0nJnYkYr(ae zJ~CoVWC=rw%9}5Yxi_!b4a%=vvnOk z@Qc7!CNj^u)dPoA>d)Bh@?@xrvUo-3qDuiq+yG!pJYvu0$|YmvH<98W?6wDgXsvK$ z`tmOCYh5mM-)^{wwAQUz9Bv7k4t642`>x*!WfN;VhcFV=Glu2JywY)YN|0~B3#D-Cv;B(P=C1i$*Q#H*8p zS-}VrK93hRyASk%ieRd4o-5Lp4ZrfBRK zG+%C{ijbm>R>M0hjo5u2jUBi;QvzQJ@9VdNj-O0Krp-(Qbq6c#%W->wZu*KB7Qt*z zqddRw-OCA5ID#_=OQo#-cz)&YlYUl$CZQUQ8;Bye|Hi5(VZ6C8i#N=H0-YZul?&CJ z9rRqLQ71cSR0M%DgaEq)W>@d5>)K?am$6m<0{ZfZ$?}y`Es(1d1eJ4gu#!;v9Zte5 ztvRRk(on64Fm}5PtjiMdbfR_0ri4WBM4|E(8yCVIjZF7brgml;x2I{6hJk6G!ww6dsC>TA?NduVckby<6@~rYR*L3eNnFw! zQKgi{_}7I}M26Ba2~B7{=={WXRy?ZP;{C$C@P!)((T$5i7O=fcFH%$nXTr3RT-p}t z2`4Mk7B!EM?p0zd3jTn+#(zliKk5(_f7g*)*kjdJQB<&d-3JvWeLjVe8&=1xoh8Kl z%&OWTzwQlvB%d&st_%`ITNERYE;(QMo3Q+Ws2N4B9WW^I>DA=ht9`9<3hS$^K+}+# z_0I7)WpTD>w3on;(`ek>TG>ieG+*O3q4A6Ded|J%P7Z!g!m_!?O&UqgGk z%xl>bTn)z73=jp+S{{l?Pbqk5@ANRq7tmW>Etn2Mi> z*EXQaB`e0NA2-Mq9-_>zXI4WVr(R@Fncj|#T9ckaP)-MCSI?TO*-WWLgO0maE2Ahtz^ppfr~x(57Hm&u;FbCQzO_d9-#Oa%x&u)6`9v+TreR+BFlP% za@rFL0#B><96_5wDS#=B4w=2@iMJsgzPB!Q>dt_0=&y+$b=gP*+ROZ3h-(Oj&q}LL zhz*jpvOK%`PN@?zuJYqcxbV4hxQs=VuxphOZm{y1{ zly#Z%GbhNsMa)Mq#g23>?bj540(HgrqG-V~6u3)pViA`ra;e8t7za(5gH%c~F}MSB zulU#~d|Eo=g=tm&PSjt0f-J)gLiwD!I5W$#2{~+Kwp?X4?Nptu6qGzhnqFm4Ieu~Z zW*9hJNA`EnRBAHL9d=iSuoYoS#DpaCn=1&nI;<^faCPMel2qtbKqH>TGwx7a7=S`Re<@ zB_i=5e}5BH-lTGZ082m4bXzI?g-KZZywm}u?viASdusXY;WdxOyi?|%O`2Z5hp$O) zaVZxyyHX-2mv*(wv);yTy4B^)#p$KO<@^eStr8KkNV%N0?(@j$+d_*V+H8GJx2; z4W)~SIo$_}UW^mHq;tcLVLk1Lpc%x716H|Bo(jd3Bq3!a*0lyB!oOWYSs2?+RtqqI zLe?97OsYI#fa+gfHt?nYME(*+0vmVRWEp+%}g$HaK(_!ZA(T4|sITLY;V?8OM=k z+3hirN5|DIQ-{}}+@m*3&R)3W@#M7OM?PNE8)a_^_S-LmPGjgGC;cs3)H9$WLgZt^ z>*oK?_b+PNgObq#t`W%gCOGp1MT&&$kHAgx1Qi+|IKcEL4KtdBj70A|kZ53l>2D)& z0>Eak(KW5_#1hqa;hCaMwl+R<(;oB$u(>-1Pu5q@?u=~~9K2u^w zTWLT!czAC`!7_=)&GS#T4FKSvmVPdJCxuo4q=&`;mtu5|i*m#UbyAa2T>1AiMN>3< zkKQfWeV#uTX9D>Z4by;WUgd(%l6?7RHC;r|lr>AP)STDvqQATCo51p8r z7)S2z{yscxkZ^25Hj83bLhYmRm%$G9ZUkGdDpSXc$cpOsf{ZI|h_f3}WanDX-U_Gx zdnp+F5dq6w^+&ijk4BAy|4w;8SSNXINh`L^0Yqr__LZUy3LwoE|H{OAmlb$cw@&ilN;#rAxq=9Lc)2@+^KU5he?Laf}Zn zB1qBSvw>@o;w5_pxQflgGU|-U{>Pubx%| z#?9ng5w9OOV@Se1^#PGP2|a)M)_r*sV05?V&5!CA{K1m4aOyPD0?jX7%cI z+6-%H;#)Wg6$6|~Jbx`}hPS@ECWMJW#RG@3iwzhw?R3WYJ$%w_09zp#fVCbB(g(qg z7?{T9-f9C2CK+RI5fYH?q3`8K&~0r0Ymq3_C@n1fTtC%Uhr>UHH2KZb9C`da6+vnR zwc83q?Sh!q&2`asrIW`I@PZLqA{=vhxhMK%Api|aN$n9egkp&hzWmnD$8cZu4k(SHXBZY0UP%45BH^jAj-Ozd z1_Xxz_Ku-591ajQbW-AHanNE-ag&aBDB4OY?>u#D3i00TU!Y84NHhDdxH@8m7pBE2 z<0DJ48+zZ)B3OgiG19Y;g+y}Y(&g?xndNjD{_Tjd(^8X+iPm~$^Q#3DB2yO2-qzQb z4+f6HkDKertdUW3zysifwPG*$TRH&x=o40`u+u0AxH~OX?YsA(qw2q@IgOFpG;!n! zoaOuRHGCGiGpL;0v(%zFN;w;=$~r+#rC&kOpuY&YsS`-y-n{leW{)-lAnzipB5`a( z#xu@+9ZSwSs)2jz&Z5!lG!}=_d&PbUJz_jh4kNZQT*&S8d#?sFk`D_*`yVwlhWo{9 z|0tD?-cHKAA39&VK^~bDdFY4#EA1_yqH5cJ;XMNkLpRb4DbgYcf`mvT(jZ-eq?B}x zv`8u?f;18`lypifG*{XbTJhmyzeb0N&fBjf# z)fYV)L?{wc!irWo{tJ5%F~>0^Kg;5fj1hGoZTQNT+N7Iyvg%y;c!dF-c@qWX9Zu5= z!hER_`Aifi6U_xqSSsCVu_J)b?l6*{5wG;IF~COPNqyUjswly;K4&(c@frx z!+>jw-jz0bl@v6n7`hc(-5pOr5ak8}%v|(u@3y2wnyqt@N;lnCi+x#XZzy>BvbHda z8U?b^Hdcz;>w;)-QyLa;eH(gyHC73obqJ!_r=pi-oJL*UwSH^GJnL$$28(;d7fKaF zIN5@ZHAHbpONAlFOvu2RyU-%`61yeNVt7IG5Y?_VX)TMk-A8v&2s4%>kA1&{ok#^C zLNHV;xJa>BdN0@)oM}?#VkE^yZf9&bmiu~of}^Fr*pzHHOB>P2V{Mve93fM7uDvGO z@ga8VFRLb;>H{opZ?RR7(t#K%0zgBh3Fg{cUpjM_yP+7be-dKJ*q|C{i3R6n%VF1i zn#YlLu_yj?7OsR2iidvZbK=97kUx+_@tkn){?2`c+q}2Mqi0@CZKHcnZ)3RD!{1@z zhu^|JmKJ|i^K2H7C`x2DCT5n1$d&jS3fV1_2e0;T~%Pt-6vsFBs6q9aW-L9;TfJhiedIVqto9H*T@3P+|fnG7D7sJDl#m{a>kaW;a%OK(J;Fj4mBxN#c z3LJK8RhePAGQ@my*jY|e=++aW06bLg)+_@P0mnm};>6_5@WDD^M?GB@| z25^!@Q{JczQ%c*UOtFk#?rqKQ?rx15-BOPF;gpv_5ethwddeR`6X{x(cXVzRw_QD< zQ2*Y7rx&9C4FyHD#(nzD^Kfrv4-Y9Geu<>_Y~dcoo;trPet;)ov{3uBj#>ToRE$%N zfJl6GzE8!W&uFqC;^Y+l&uxR(9{J1?Tesf6a`gq1k7WEuUG;M^NcnfE!h06CyeDlU zFP}G%5gha_@SEZtM!Zhp$I6o6FD6?qW)a0f@cJ8Ttc;s9j@iCnjrfri%?256uzaL~ zU5cJc2OnrvO=7(mZ_+Y(s=&6w3QJJDMU6fc`izF4`}V!zUz$cwHJaRI^4F*U=0HdE zM0ei%Oy#X8+EnedTk}YuPZi;?w+Ib*3Yecb&J#IH=-MF< zo~-5F&3KhcfY+3(K&RQmh%Ln+1Nc7NmZI@bLxiF3uxFecjCH80Yz~an#O` z1e3BoW|t%*lJ08l$`bB+QWyGu#L4A&r|RM`;b7FLrzJ&!Bz$=8%$_!uj2{1JIs0sH zxyomHQ|S0dc3-@t9yt6Z#8*yByN(P1jANh4CUvHT#que1uq!?DlT!u1C)L!LO77Q6KvJTgGfz@TjPFGb+DVetYu z$Y|~;i}MV^Vn-Fty%WOeqC;!>I~iIMn{-G*LZS#mPQEGo-rdA_b3<}-RSiuYZS#>~ z@Nhv6O)Y@;D8TuH9q{xaGAlv$K=eINCLm7;c_nxiCD$PwrbL=;6`LiW#A=nqP5ySo z+VMDAZ06Uw0LvX~e^4AXf6ifP6Xh`;3$ktU_%*yV6w~hD(XW>%+t(zmB4*9YRr+kb zmLj3-ex2~}$r4PM@+wQZ$0MvlpZmC|V<%#;%6k!;JnIpvm3G~_cJk%bg);`lHcpLR zi)VHMWm^o=x_es)DkY^7_D-mM87la3&0VhBRFm9XN6SS=&}A7EgWEj?;GBh{@3&>m zt<_m>^It#e=BuL)huba)oMEe&6`Tp^%CbhjW9}+_UeYtzW?G_;(gIrwc#TvWr~BL( z7m0LH5mbry7TU4U^SgTIcUm`Y|Eas>Ef=T+A*{EJ%yx3O5mbq^exyA$K~rAR@iJ{Y z)d9q5MZTi$+=oeU>z(ZDkN7%K*A3vpr? zL+*7}^1Gqz5kKDIgd!q$e3jaz3Rq~i!K$QtyYBFXAmq&5Hqt|LCC<*;mCPB>R$6$F zmjD$e^pM5f5U;Zn2Yz{t4v{1LL7;M%(fk!@2gobhKjJAbkE>h$g-<#<2bueh zO_#^P`PSy~ud8RYM%8AT+|a#d4be~Z|9_BnqS6Lo8w2eV`yXRkRqh$XI{ur zXUKhI3wuH=+im{UQ1;ku*E)`q{JW{iJ1LgCZA6xMY;)+~{-k=+cDh?}fBE!exqIcX zJHhn0x+}GM!lBHbwm|<5N|HMypu$C{yeUOgy!J=aRTd=LL`N|G=;`wYhjRZ${*w+O zowY7@GVxb1jK=G~M^YYcLj4L@%_m~yHdb~Aw=spVY+Az<{eDm`^>@6blXMS4nzhFp zuoQp~0eN-DphtqU6%pPHAOih0aD>TiM2l)1UGF4ATdRKROw8Ptj&9AxYjynEqoE_? z5kt4Xk~nE*vOlF|ZZJ{~9<*f26@#|`RI7e>f~ zB_N+MILvv=`h)b_CtPY>YGhVLHMV{r)C$hn26z>8NN*I@Z{%9UQ*^qOE$mxyTBbn_ zGD?B?k0*EYho@!Hj3G5s>d}>KczmPBi;YzrO+^BW?>^lU?8;q$=|i@fT=Z$xFy+nj za`km;N*B6VmCUwAtz*&|2q99u*qYceTJn-Q7Wmz3(UUDD4_1^6$4YJ2OHm)YK%D#j znBVbQLR(K(7ks7r#`K5$7#Y~JV+UnqWjM${i5%fKhm7}b?)VQ(O$Mb{uqY#>*5Yvc z9*s0ztr;5r?oTb?=|AlT5&zEKZHoz>mT?$njmtX&C?rQQnFC9M&Ec?Lsxxl=x5n@z zZ9d#XbN)l|*_tO`P!4@gs|sj<{W!8qc5-FF3=>?a%zakt#<)fs4jy#mg#iZGg%HkJw9J<|n0?S(rfL?{)k2 zJFSO#?*^Qy9*QsJLJ`nA{JPS?3v#kCk&I-NgTageF?Z#ksG>Utwx_LspMdtL#qRB` zU!7N9E%vv7(waxhhgE*4eQj+q=TpYsr&C1w_^w&$OR%YDkRE3!Q1xV&VD&)W+Vc9($*4Z>~HDaJP5QJ0>li1bs1Dev$v-C*B@LYts9&{9%%*bKC{% zvz2MW6-}=T4pVz$IMY`L&+fiQjfZ4ys^}01Tt#~<6!21v+rnppyaq(9tSdICEIPsw za`3?2>69>BHfT0LL<*rA#?}v~%!I!|l8^`I#FW0vaMI+8Ae-dFDK=A+yQWFURRpXe z;I&(@^9!Cwq>=;J5$Vj$%*|}9nW`lI$3e1c*m98h&dm^zX~)`qy5`z9?|SwIlBaIm zt|2yLmEAAix~2E&#?uW|+aK{)o4^P@%P{l8fx?#k4%@}Eg(7{z`S30&8Z6B=s7;fu z90+Pa`%OmQ8e(jfY)AE?Ak2;lz{w^8-owAX^pa#8fXAl2(MTeG3j{b13guaIv)@rf zS(k7=q{*6Q4+(i(*~^0T{mioe;OC%gse&1e35^-C8L>%L=!C;PW=6H#iL0#32Nt>1 zC7`hU#c|_#zh47Q1&_<=LjEmoqw4Y3qR8It~Mri)9NzlRF=j22PVVk`d_dVa9wQ zKvC>hnV`#2`3bfkTF*H$@1yJ4&?-WagRE%F03168`0?P2?W@Kd{%>#zqLV3>B7vRA zkU|~tOlK7VwUL^aO`d4Pz#293{WyC#74k=ANouh_0B2yw(0%lrH>}BW0K0gzEZBW{ z=GE|voRwZ|DLekxI<_a{NB#GAAKPTv;7Y;F(AtqX_t!E;`x)`ApXL*nCX9F#ul-`z6kun=P`BnD*ezVJaqCa_-ef_N8G zh6iY-6xB-iZn0hcz1j5=(sO6ssb9vFUO9%91!g!eoaCM*yr&m`OZ+|$`E2Kj_S~ja zo*l#U1DnZi;n!?dXcYiDiSPdT5nRp_h zb&%u3J)&Rq#0yz!?}i5DpL(%vuq0$j5V7H5*E;4Qc^zJ`u|xZ??>*}_XZe_Nd7*#3 z`{L@fzxU|Sx%zxCwjpdn@qIwA-mG$7(wOE&ukW1-MjeMT$st1u~0e%cmKGCGnd1UmJ`{=>JslQMgQ+ShILel(xN z8@B{ge=pubfwRJ+vi%Tl_f30|t7B?FP@!;3lZ5H~X|>e(2PgLx>HgK!Mvf(%S;})4 zvr>DC>znxiff!(HEDBV8$!+?S^;l??L-<@F;`ZgD-rzWGVm^E#V^;n8yx(M)SVddf z5rD z4I=_A=eA_Hder&%Yk9KQ>TUx%x3sKNaqN2+Y*-zQDU(Tlx9aBEXpgFm9J$*XRvxCN z?yvHkU#?UOd877kS=}Gp$a)aQ1?n1fa$o^LUOGSl6h3Sj(KtE7+cKDSltsH1)N|cF z6ouUz4nPS^qe&qdrXn_nH(n})!~LS8PuIqvzEnwV>f_;{6gbg@F%Bk$%YfUTSnGZU z<>nvj8>GwQ!?Q!OuEO`4R0lBdvl!Mj(QrBEs#x*`o`R5Pj!Mg2U9?fX`--RFG5vl* z@7AHBN^vz&;(xP9kScI_AWyY=osD!)`0m1QiK2F!dFjPBzdPM`~AqHp&W|Q4aP) znFFuUOf0m^Tq;WMSN7&}lNS|zH>E5D7we%z8XYB(5qv~4pC$)NKVT5TW7^NdNp{O{ zk5Tk#p9Z-cP)Ec?TVy8BRMSGS85n}-1L~wByDFP*9h)l-W83+QiUzL1 z;-CyNsLd=6#6QU3%4+o=9>N3qpJgI@Mg)zVUv@PXVG0pEuFBe2+5(PDGzYQ`hnwJ$ zK-_^KfN84pMM$qI$)K&bhMoMQQjKBGTj|(r$M_GNLsrn3M`kJ%DyK#hg=ICgWRW%Y zuTRxyZ`;YyqYnhlb7hBgTqE{ z{9x*hYZm>*o2>?jFw^fMQtgEHNl##>x73WrV?U)09)5ktEWU&q{;kEKA(P2{#(~<$ z{1K-jlRhuKei?gY_vrJO=i}BN3V)6}QNFN>ZDWnPK|D!2$y)&a*4b8v3e%-m^SiXwKFAfhKHcze1H`?0X)N7S)D8FCp0s6R(Q{ zc;B*=K=G@IxL-SK7wxxL@7L&al6Py?=voxGlp1!teUkkqjEOJ!D-68~UVSpSlS=X|nnBFX1{hJN#c@E@r{ad$_*Oc;KV zMc{Yy_5D*7otbwea}xB#iRdACR9y}3F{^KjBzY! zXo*wm8uaaH>l^3hr(c|ZR<}E<3u-QhWpH-$i7$0L_I*ey9W%Q8O$*Zjvlz3)tGN2v z_My%j>X^@yW5X76$^q0T;ts@t=nlObG!1!Fdq(BCtvE~DyH6(k#`+RJ3A0m#@_3Jj zoiJ6vxKn!VTypo!9rfwnx0zIq6_qagmP}{s#Ai8@wzX3f3J?)7ut$V0)n^fU^`7Nz zUyq%12m(Tk7q|_&i|QIpCA>Hy7PlkEbw;0Qk1FZ>lvX8^)}(pclHM65t+S-$@ui@i zU(d>Q?r31i6vt?{GT^%6z&^NjwgOW1wLI~zF4x5s#{)q7KAPYsIC1>s-?O6Z zPM!t0T<@@MD`=drvqhHW8ds&;t-U#7+Jn?!>E!ZYPypz;1r#b06axy{mz7^AwEekW zM}FbUQ%9+z28H`09W54u-rOU}R-m?H)ClghG!Iu*ePYNw*OBj2pEcaN4TusucYLUAh>&SKzAFt zX>E0$jqdc4MEX_&-OI@1{uhcA4dY$BfOu;ub;8wcAC=7&GY zc@jpNseoi`k}K$>plzFwlFBc~wBzuQ3X-r!NFRI>AHxdGKEx&Km=;3|l#puGttcOB z7bpk6fcu+f57{KsWb&qJQl^blNst2z3SF;@@SUzdf#SOwrT5EseVQWz8y7QDi(EYu zJgesn+_TR*j`?SVgjBIpN>{Q7eAc9)PcPQnbp`}fu(&a)MLue|sA*c6#7}&Q)p@5A ztsvhaCv&H;{HM%ExjRZ-B0Zk(l5x{X$ATPQ;Q^91Z+=HqAVtC&XDu1v{>6ZZqZUA1L=(pylVW0#;;!W<{yLuPfCss6})73W*({8IKQ*NapUwJ^M ztk6RBLTLAn=JDjvG~UEEl^CPfI4DK3@6v;%48IEvmzUPHG5$yO+?Q| zs`ETXvCoGhdzuO_b6)u2gbHQqJ?Z z%$dwC6_zYU#G)6F9WfZjPoy>rAir`~8r0F_sk^jB*05gcMqo(Pe5w?f!?dvI{&{yY zxX>IGWK&Y^mQJSapdQK*SYP%cpkcv{tcq&vdbC4*j3GBtR?Q{$d&ThG z?~vF^5LV(w?6;|Z9UfTufzDS{RF^v;(P^@1BiZdar^Nx2+hoLo$Q5u82)Hdt5>VEJ z7Y#u{Y_jC4q%?uHjBpincF92`jLkHh)2e{XLO;P)yUWpuY!oK#K#kL-MT$12;624a~2Dj(&w7jAb00LfZaif ziCI7%O&Xw5J=MkbmZVU%8G{N5oE(cia_1tlo&b=%$j_d7gxfQVc?uCl=dW!{`l0p{ z4MCeA-$+gmycZP0V&c@@oPS39|>6x8w3oX?2cQpO9d0*Qy zLSM6i{CcvU${z~*f(!@Q$o<=a)0Pp0H3BUcLY7vE-mJwEHGH|8_i-dzol9MLfW(<& z*OtL5=mb=mt!KjHiac;fc-h6w&+il{W_rzeM)wzT5Twhv+W&S)3f_{Kr?k6sI1)|kwySNiIld!!LOL3mv(F?kjCqovHb#}2tf-skInpMJxKvC z=9%(AlR)ARK-naWq=|}o4me~mp1L@zW2{kLV@FfGx+O0IDnz-ZX4WS8yJlvYO=&S! zcR{UVZ3mseW# zCmbCpO#@^Kt^6!iZ-vyx{L(_3OWU-t<1i0?`mqnCBrE24X5YW&h}M+wOL$LfsWYx9 z%AW8>mo}(ymVr_bA@_Pd-?_1FWM@%_kU+l=?_{Sq%?53$4uT}|07PoZz`AT`sI*fA zml1)*m%@3G^N%G(-6Zjtu4g!DW&KF|)f$3v7gY&(9jKem>(AF^C+0;fN#7pri}e(o z$EU{ajV+XclNHgn(aSNQ36C1tro?&)Yv^v4%fWJ;c^E3fsy7L|di$ke;JBl37N}x~%MevD z=s2OLuW=apNdNNcEP@)So)0<~3^Bw+zqpjPUh6}H`m#0dbEpFD)q+)fihbf8W%l#q z+u~RRM?rmwIm&j+{8u!!(C&SYvmz4cq4=8Lq|~7ZWzLbv>knte=Z>#q!gjxueh8No zIdY!F3TIS3mu$TBPTCyNewv{6e&z?z11`%3>6)?lym(XTGggg0Z#7<)Owhg`^K;}M z?Ql$=^{X#9DG|9M3h5m=X_#NbM;O}JO34^~H%%ROe37m5 zps4ESn8uVonakBT2PM(d93zF!yO*q7#Ld+2;s1$@-W&^CDigpK;m;4cNBnPEobGr9be(9WR8tG6a=OcF8yt zja~g9;kZ3dyl#kS?c?zAd>tG4i>(@$mp>k!kx6WWzzB4*=*^ zaNb}UYwn_n`tiry#RIo&GPhTVcA^i7!r!1rhYx!u;yU?9EZ@2Ee>$$MsM*u=KPpM? z^zIiA!6A}^h!5xe=>FM3oIv8^uo`eD<&xA{dky4!#>>90f>>&sWZI$iy&S2#zW&g= zb^3;LC|<3GMZzx>pULl~OOMavNu6E%h^-z7uKlH3o4I~nP-@rQ|=@l2v%3cyxqvB+Sw#m<>ZvGnMy7lcqLedy9j!K>-eq}l z6TYhHaa`svOeN%T`sV1Gi2eAkU|w!J#z5%cP*PyCB>|Ahu8^Zis?VKeK^*{`~Q$kJ^*+-QOZcaxHh1nGgvWW+%O(MA=@gQg76dX@jor5WM4?SwIBp_PE zO5{m~_j#~uMo2Zcl1CBk>L&Tsw&&=(fi)PY9%j2T9dg31oq$9}DNrw$wjT?BzV^58 zmo-Iiu;3gedOgwB@+QSnn+xm7h1!gYb02#gPlU#20rc+9TXGgF2Tn)dRUV}H{U(3C z9fEmb|MaZoytg(|xo#bY-}hZ@!?2*&re*49JFXPk_lth{IG5kEo9t<}ID6NvEhtZi zU}>{TN|Blra9gOh*YLOtrJleocfLt4Y6YxD4u(}qwLx-&dFz)!RYv&f?V!8e zNiK>$Gd>1{MZp|X>UXK1{W;!S!v60=MGceWqGpin4c z=?0Q!h}PZyq2R8Qz%|PG^ek#_IfJI?=Wkx(D?>i>?Zx`OqLmqo-Hr75D)yQZNTLBw zXP)dpVe-}f?zO(tP#f1bE+Yc-Ud%pt-R5ui*V?Xh2nlEN%c;BGHR=BH_|sBVlDFO% z3VHU&>~EPfKl--o30Wc68YL(cBrvP{hI?>ka#8X@QV8SEArIlsuJgXxY(0xPuGj?7 zxoiQqgBkQ;dXy%9v_r6%?KB-cLubT5+;uf%Y9T zl<9mi!Cwl}(cOlS1f##9^d?&ddq>~w-BNt-`OhT>Fc@_MF{m+O_uJx+xbM-6eL;E` zIT5vtAB#>RL4KKt%SjgLZLY#QzQ1uft3SB23eqfXX8`iit6=YKRhhp7s>ZqfnW36m+9%2aZhb-HHFKpiuQX!{IH3gxJA-U#2{&8Sx(kCn2c5 zmo#RJc(VAu(;k7d{88X3E;U#gH`1tp2?{bh^=QrJ2sm+({@xLmSY!*JdZbu1bj6Qg z5_9bjf7yd7#J0QX ztEBZ$3gt#B;f(gS6ie$VbwQq_JE9YS2ZzB{7sFZM+06D2;@LrO!`%}<9+%Z48Ah7H zzt&n12*ght4Pg`mIC%XCNWgJBUAw%d;}5nU1oTy<^mk2K)5@-6S^I(<+JGNN4|heiG~kaJT5b#sUV6UQAv9g#VGE^g<@5PD04cJc1?PB>oE z$k-e;#_sG$#PMKJT%{V$&i3S~*&Nm1oHKK>9Kkyr@CWq8)XhaWbXOd=^G+DP7uBt% zz9!l|28-(F{grlIzj*uu?S`DazO(@9sK*|wdz)SlkBSL#{!(Tl(d70eaE`C-w2MY{ zM!de|cy@&&B*;id)H_Z0>}*|pfbQx{l%j#j`D`Nn{MNa4mk52S_v$%9Fp&3RQ+NIE z-E5#Id`^_*$L7%GB<975?~HhZ0H?RJg-6N)=e8C53`Y_!a|nGII06?DL#99RqWq1w!TFh>=VGT|wltWXi+zck9&$q`>+ghL1%T}WT#1AKI|Qjw z4Zl(Yj5Y}w>Fdp$K8#-!!V1zqosZJZpODcNowy~+USf2kz{2xe)O!wozVgOJfYJP; zE2X-oM%Fcrxt~wbR;hyBKHz`v{oV=@|57qCs>*8~IYXQqJlP0f-Vo9Q#LHH5a<3X}G0t*wQ^qYa> z!WgGY(-aXsBm3w^(l z`ZA`Zu7m^-cq2m#phNq$vqUh^FFISx9HwAk9X?CjAq5w%H)O7O69QZ1ra1~8YT`zM zg7B}0!0h~Y0>pRL*&XB%Hyh6XN{CP-Sc-qqRnEKHp(r$j3i7{({0~P2Vdg)P{=@bE z+K8}>L;GtA|MN-c7bISZ1MN4Lfq&B+YarVq*zUk9jZyI75Q+JB$Ur+M@WVm+y9oYR z{x|;sI@V1w-H=MuO#%{sE&u;YDv+B4Y)8S^%8&j8kVw<~`xXE14+-8Df3TV(--22H zhyMNMKj~jwZ%#iV_UFZw_%Z8weQ$AwY@JudeH;BLAwPo>@L#Ekm>H=0tz2j1A>j}C zuOWcw*W3TY<3T<~<@Ngc5t;);9S-^+hg<(06Zs_j-H>j~!oS|9wu%1lH00h2 zf)+V9FeWDyl;-aIhzlmQzsH?-3y{!70sz8*4RkS}Bot%)^)@hg^iOAifDxJe zkNuw%{_{D}xcnnSHh~;;I}wFo8oQ{&RW>n!XaH#XtaMY$0P=tl4amZP1Bel@I3#Xf zY|$vMapUzDAzw+|xJ5G(M2r6I^yd+6{ssUZpo3KaL}R_3BX@gSy8<9Bgl}r`e+0a- ze2{!WNQ?aZ@ZTN-D42mhCusi#2Oc(qAB>EZ0c}`+hlmzT0)Qn#UNB4SGyjhD#}r*c z8+|wJ#w=QMkqiAN4rq`5Kyc#>+$je$Jr1^!8`7wUxGgZn&kB%O-&p0PpGu)5js5=@ DLeM15 literal 67285 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 BXNCX( From aa83708499dbfd416d466ecd0fc36ad0eb577cba Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 17 Oct 2026 17:01:40 +0600 Subject: [PATCH 068/132] feat(graalvm): enable GraalVM native image support and update packaging configurations --- .github/workflows/spotube-release-binary.yml | 58 +- AGENTS.md | 11 +- README.md | 99 +- composeApp/Cargo.toml | 7 +- composeApp/build.gradle.kts | 93 ++ composeApp/graalvm/reachability-metadata.json | 1332 +++++++++++++++++ 6 files changed, 1583 insertions(+), 17 deletions(-) create mode 100644 composeApp/graalvm/reachability-metadata.json diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index bfd66bb2..b132e670 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -27,8 +27,8 @@ concurrency: cancel-in-progress: true env: - COMPOSE_WEBVIEW_REPO: kdroidFilter/ComposeNativeWebview - COMPOSE_WEBVIEW_COMMIT: 5aeb268a2c37ff4dbfb76924573344a7c4f763ce + COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview + COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin @@ -181,7 +181,7 @@ jobs: - name: Install packaging tools run: | sudo apt-get update - sudo apt-get install -y rpm fuse + sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf - name: Set up JDK 21 uses: actions/setup-java@v4 @@ -198,13 +198,19 @@ jobs: - name: Make gradlew executable run: chmod +x gradlew + - name: Cache GraalVM toolchain + uses: actions/cache@v4 + with: + path: ~/.gradle/nucleus/graalvm + key: graalvm-${{ runner.os }}-${{ runner.arch }} + - name: Package Linux - run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename Linux artifacts run: | - DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1) - RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1) + DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) + RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) cp "$DEB_FILE" Spotube-linux-x86_64.deb cp "$RPM_FILE" Spotube-linux-x86_64.rpm @@ -242,7 +248,7 @@ jobs: - name: Install packaging tools run: | sudo apt-get update - sudo apt-get install -y rpm fuse + sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf - name: Set up JDK 21 uses: actions/setup-java@v4 @@ -259,13 +265,19 @@ jobs: - name: Make gradlew executable run: chmod +x gradlew + - name: Cache GraalVM toolchain + uses: actions/cache@v4 + with: + path: ~/.gradle/nucleus/graalvm + key: graalvm-${{ runner.os }}-${{ runner.arch }} + - name: Package Linux - run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename Linux artifacts run: | - DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1) - RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1) + DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) + RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) cp "$DEB_FILE" Spotube-linux-aarch64.deb cp "$RPM_FILE" Spotube-linux-aarch64.rpm @@ -312,13 +324,25 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + # native-image links with MSVC + - name: Set up MSVC + uses: ilammy/msvc-dev-cmd@v1 + + - name: Cache GraalVM toolchain + uses: actions/cache@v4 + with: + path: ~/.gradle/nucleus/graalvm + key: graalvm-${{ runner.os }}-${{ runner.arch }} + - name: Package EXE - run: ./gradlew :composeApp:packageReleaseExe -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + run: ./gradlew :composeApp:packageGraalvmExe -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename Windows artifacts shell: pwsh run: | - $EXE_FILE = Get-ChildItem -Path composeApp/build -Filter "*.exe" -Recurse | Select-Object -First 1 + # Top level only: subfolders hold the unpacked app, and __* are electron-builder helpers + $EXE_FILE = Get-ChildItem -Path composeApp/build/compose/binaries/main/graalvm-exe -Filter "*.exe" | + Where-Object { $_.Name -notlike '__*' } | Select-Object -First 1 Copy-Item $EXE_FILE.FullName Spotube-windows-x86_64-setup.exe - name: Upload EXE @@ -361,12 +385,18 @@ jobs: - name: Make gradlew executable run: chmod +x gradlew + - name: Cache GraalVM toolchain + uses: actions/cache@v4 + with: + path: ~/.gradle/nucleus/graalvm + key: graalvm-${{ runner.os }}-${{ runner.arch }} + - name: Package DMG - run: ./gradlew :composeApp:packageReleaseDmg -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + run: ./gradlew :composeApp:packageGraalvmDmg -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename macOS artifact run: | - DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1) + DMG_FILE=$(find composeApp/build/compose/binaries/main/graalvm-dmg -maxdepth 1 -name "*.dmg" -type f | head -1) cp "$DMG_FILE" Spotube-macos-universal.dmg - name: Upload DMG diff --git a/AGENTS.md b/AGENTS.md index 44b1662f..6de06c88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,15 @@ - Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). - **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). +## GraalVM native image (desktop) +- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. +- Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. +- Hand-maintained + agent-recorded metadata lives in `composeApp/graalvm/reachability-metadata.json`. After adding features that use reflection/JNI/JNA (vlcj, Zipline, plugins), run `:composeApp:runWithNativeAgent`, exercise the feature, then close the window normally (the merge only happens on a clean exit). +- `uk.co.caprica.vlcj.binding.lib.LibVlc` must stay registered (`allDeclaredMethods` + `jniAccessible`): JNA direct mapping finds its `native` methods by reflection. +- The `*UberJarForCurrentOS` task excludes other OSes'/CPUs' native libs and stale CEF binaries, because native-image embeds every registered resource into the binary. On Linux, `copyGraalvmJvmSo` is disabled and `copyGraalvmAwtSoLibs` prefers native-image's own libs (10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's 28 MB `libjvm.so`). +- `isProductionBuild` (top of `composeApp/build.gradle.kts`) is true for `package*`/`create*`/`notarize*`/`nativeImageCompile` tasks, or with `-Pproduction=true|false`. It gates the Rust release profile (Gobley `jvmVariant`) so `:composeApp:run` keeps fast debug Rust builds. Put new slow, ship-only optimizations behind it. +- Full user-facing build docs are in `README.md` ("Building from source"). + ## Codegen and plugin packaging - JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`. - Zipline plugin entrypoint: `mainFunction = "org.spotube.js_plugin_example.main"` in `js_plugin_example/build.gradle.kts`. Plugin metadata from `js_plugin_example/plugin.json`. @@ -53,7 +62,7 @@ - `prepare-deps` job checks out & publishes to `mavenLocal`: - `kdroidFilter/ComposeNativeWebview` (compose-webview) - `team-spotube/gradle-plugin` (spotubeGradle + vlcjBundler plugins) -- Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. +- Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. Desktop jobs ship GraalVM native installers (`packageGraalvm{Deb,Rpm,Exe,Dmg}`, output in `composeApp/build/compose/binaries/main/graalvm-/`), not jpackage/JVM ones. - **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release. - **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease. - **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets. diff --git a/README.md b/README.md index 610016b1..6ae62b54 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,104 @@ Grab the latest nightly builds of Spotube [from the Offical Website](https://spo GitHub Workflow Status -You can compile Spotube's source code by [following these instructions](CONTRIBUTION.md#your-first-code-contribution). +Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app is built with the [Nucleus](https://nucleusframework.dev) Gradle plugin and released as a **GraalVM native image**: an ahead-of-time compiled binary that needs no JVM installed. + +### Prerequisites + +- **JDK 21** to run Gradle. The Gradle daemon JDK and the GraalVM toolchain are provisioned automatically. +- **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev). +- **Android SDK**, only for Android builds. +- **Unpublished dependencies.** Three dependencies aren't on Maven Central yet. Clone each one and run `./gradlew publishToMavenLocal` in it: + - [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) + - [team-spotube/gradle-plugin](https://github.com/team-spotube/gradle-plugin) + - [team-spotube/vlcj-bundler-gradle-plugin](https://github.com/team-spotube/vlcj-bundler-gradle-plugin) +- **Linux runtime:** `webkit2gtk-4.1` for the embedded webview. + +Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows). + +### Development + +```bash +./gradlew :composeApp:run # desktop (JVM), fastest edit/run loop +./gradlew :composeApp:assembleStableDebug # Android debug APK (or assembleNightlyDebug) +``` + +Development builds stay fast. The release-only optimizations (the Rust release profile with LTO and symbol stripping, the native image, native-library pruning) are only applied when you build something to ship. See [Production vs development builds](#production-vs-development-builds). + +### Building the desktop app (GraalVM native image) + +#### Native toolchain + +[Nucleus downloads and caches GraalVM CE](https://nucleusframework.dev/en/docs/performance/graalvm/) under `~/.gradle/nucleus/graalvm` on the first native build. Set `GRAALVM_HOME` to use your own GraalVM instead. Each OS also needs a native C toolchain: + +| OS | Requirements | +|---|---| +| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages. Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build` | +| macOS | Xcode Command Line Tools (`xcode-select --install`) | +| Windows | Visual Studio Build Tools with the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. | + +A native build is a whole-program compile. It takes **about 10 minutes**, uses **every CPU core**, and peaks at roughly **15 GB of RAM** (it gets slower, not broken, with less). That only happens for the native tasks below, never for `run`. + +#### Build and run + +```bash +# App folder: binary + its native libs, in composeApp/build/compose/binaries/main/graalvm-app/ +./gradlew :composeApp:createGraalvmNativeDistributable + +# Run that folder's binary directly +./composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube/spotube + +# Or build and launch in one step +./gradlew :composeApp:runGraalvmNativeDistributable +``` + +Keep the `spotube` binary next to the `.so`/`.dll`/`lib/` files it was built with; it loads them from its own folder. + +#### Installers + +Each OS can only build its own installers: + +| OS | Command | Output | +|---|---|---| +| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` (also `packageGraalvmAppImage`) | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm}/` | +| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` | +| macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` | + +Add `-Pnightly=true` for the nightly channel (separate icons and name). + +#### Reflection metadata (when a feature breaks only in the native build) + +A native image is a closed world: anything the app reaches through reflection, JNI or JNA (vlcj, the Zipline/QuickJS plugin engine, NewPipe, ktor, datastore, …) must be declared at build time. Nucleus generates most of this automatically. The rest is in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). + +If something works in `:composeApp:run` but throws `ClassNotFoundException`, `NoSuchMethodException`, `MissingReflectionRegistrationError` or similar in the native binary, record what's missing: + +```bash +./gradlew :composeApp:runWithNativeAgent +``` + +The app starts on the JVM with GraalVM's tracing agent attached. Use the broken feature (and anything near it), then **close the window normally**: the recorded entries are merged into `composeApp/graalvm/` only on a clean exit, not with Ctrl+C. Commit the updated JSON and rebuild the native image. + +`./gradlew :composeApp:runGraalvmNative` gives a faster debugging loop: a quick-build (`-Ob`) native image that reports exactly which registration is missing. + +Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry. JNA's direct mapping looks up its `native` methods through reflection, and without the entry, playback fails to initialize. + +### Production vs development builds + +Production mode is switched on automatically when you ask Gradle for a shippable artifact: any `package*`, `create*`, `notarize*` or `nativeImageCompile` task. You can force it either way with `-Pproduction=true` or `-Pproduction=false`. + +| | Development (`run`, debug builds) | Production (installers, native image) | +|---|---|---| +| Rust library (desktop) | `debug` profile, fast incremental compile | `release` profile with LTO, single codegen unit, stripped (31 MB → 1.8 MB) | +| Desktop app | JVM, hot-reloadable | GraalVM native image, `-Os` (size-optimized) | +| Native libraries | everything on the classpath | only the host OS/CPU's natives; other OSes' and CPUs' libraries are pruned from the uber JAR the image is compiled from | +| Skia (Skiko) | extracted from the JAR | loaded from `lib/` next to the binary, not embedded a second time | +| JDK runtime libs (Linux) | n/a | native-image's ~10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's (28 MB `libjvm.so`) | + +Android picks the Rust profile from its build type (`debug` or `release`) on its own. + +### Releases + +Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable` or `nightly`. Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK. ## 👥 The Spotube team diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 8a06d501..85f1f4dd 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -14,4 +14,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } [lib] crate-type = ["cdylib", "staticlib"] -path = "src/commonMain/rust/lib.rs" \ No newline at end of file +path = "src/commonMain/rust/lib.rs" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3a242623..5fd468bf 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -16,7 +16,9 @@ */ import gobley.gradle.GobleyHost +import gobley.gradle.Variant import gobley.gradle.cargo.dsl.jvm +import dev.nucleusframework.desktop.application.dsl.NativeImageOptimization import dev.nucleusframework.desktop.application.dsl.TargetFormat import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -50,6 +52,17 @@ val desktopIconDir = if (isNightly) "icons/nightly" else "icons/stable" val nightlyBuildNumber = providers.gradleProperty("buildNumber").orNull?.toIntOrNull() ?: System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() ?: buildNumber +// Production = shipping artifacts (installers, distributables, native images). Size/speed +// optimizations that slow builds down (Rust release + LTO) only apply then, never to `run`/dev. +// Override with -Pproduction=true|false. +val isProductionBuild = providers.gradleProperty("production").map { it.toBoolean() }.getOrElse( + gradle.startParameter.taskNames.any { task -> + task.substringAfterLast(':').let { name -> + name.startsWith("package") || name.startsWith("create") || + name.startsWith("notarize") || name == "nativeImageCompile" + } + } +) val targetName = "Spotube" val homePageUrl = "https://spotube.org" val repositoryUrl = "https://github.com/team-spotube/spotube" @@ -431,6 +444,84 @@ nucleus.application { buildTypes.release.proguard { configurationFiles.from(project.file("proguard-rules.pro")) } + + // AOT-compiled native binary (no bundled JVM). Build with `packageGraalvmNative`, try it with + // `runGraalvmNative`. Nucleus downloads GraalVM CE on first use (or uses $GRAALVM_HOME). + // Reflection/JNI metadata the automatic passes miss (vlcj, Zipline, NewPipe/Rhino, uniffi) + // lives in `graalvm/` and is (re)collected by exercising the app under `runWithNativeAgent`. + graalvm { + isEnabled = true + imageName = "spotube" + nativeImageConfigBaseDir.set(project.layout.projectDirectory.dir("graalvm")) + // ProGuard is not used for native images; -Os is the size equivalent + optimization = NativeImageOptimization.SIZE + // Same ceiling as the JVM distribution's -Xmx + maxHeapSize = "384m" + // Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed + // a second ~30 MB copy as a resource + buildArgs.add("-H:+UnlockExperimentalVMOptions") + buildArgs.add("-H:ExcludeResources=.*skiko-[^/]*\\.(so|dylib|dll)") + } +} + +// The uber JAR is what the GraalVM build compiles from, and native-image embeds every registered +// resource in the binary. Libraries ship natives for every OS/CPU, so keep only the host's +// (the uber JAR is per-OS/arch already; it isn't used by `run`). +tasks.withType().matching { it.name.endsWith("UberJarForCurrentOS") }.configureEach { + // Everything the exclude closure uses is local: the configuration cache can't serialize + // references to the build script object. + val hostOs = System.getProperty("os.name").lowercase() + val foreignExtensions = when { + hostOs.contains("win") -> setOf("so", "dylib", "jnilib", "a") + hostOs.contains("mac") -> setOf("so", "dll", "a") + else -> setOf("dll", "dylib", "jnilib", "a") + } + val nativeExtensions = setOf("so", "dll", "dylib", "jnilib", "a") + // CPU names as libraries spell them in their native folders (linux-x86-64, jni/amd64, darwin-aarch64, …) + val cpuDirToken = Regex( + "(?:^|[-_])(x86[-_]64|amd64|x64|aarch64|arm64|armv6|armv7|armel|arm|x86|i[36]86|ppc64le|ppc64|ppc" + + "|s390x|riscv64|loongarch64|mips64el|sparcv9|sparc)(?=$|[-_])" + ) + val hostCpuTokens = when (System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> setOf("aarch64", "arm64") + else -> setOf("x86-64", "x86_64", "amd64", "x64") + } + // OS flavours we never ship desktop builds for (sqlite-jdbc and JNA bundle these) + val foreignOsDirToken = Regex("android|musl|freebsd|openbsd|dragonflybsd|aix|sunos") + exclude { element -> + val segments = element.path.lowercase().split('/') + val extension = segments.last().substringAfterLast('.', "") + !element.isDirectory && extension in nativeExtensions && ( + extension in foreignExtensions || + segments.dropLast(1).any { dir -> + foreignOsDirToken.containsMatchIn(dir) || + cpuDirToken.findAll(dir).any { it.groupValues[1] !in hostCpuTokens } + } + ) + } + // The locally built compose-webview SNAPSHOT carries stale CEF binaries (~1.3 GB unstripped + // libcef.so) that no code loads (Linux uses WebKitGTK). + exclude( + "nucleus/native/*/libcef.*", + "nucleus/native/*/libcompose_cef_*", + "nucleus/native/*/cef_subprocess*", + "nucleus/native/*/*.pak", + "nucleus/native/*/icudtl.dat", + "nucleus/native/*/v8_context_snapshot.bin", + ) +} + +// Linux: native-image emits 13 KB libjvm.so/libjava.so shims (the image itself provides the JVM +// symbols), but Nucleus copies the JDK's real ones next to the binary — libjvm.so alone is 28 MB. +// Ship native-image's own outputs instead: skip the libjvm copy, and in the AWT copy prefer +// nativeCompile's version of any library native-image produced over the JDK's. +tasks.matching { it.name == "copyGraalvmJvmSo" }.configureEach { enabled = false } +tasks.withType().matching { it.name == "copyGraalvmAwtSoLibs" }.configureEach { + val nativeCompileDir = layout.buildDirectory.dir("compose/tmp/main/graalvm/nativeCompile").get().asFile + from(nativeCompileDir) { include("*.so") } + eachFile { + if (file.parentFile != nativeCompileDir && nativeCompileDir.resolve(name).exists()) exclude() + } } // Issue with compose plugin @@ -446,6 +537,8 @@ tasks.withType().configureEach { } cargo { + // Gobley defaults desktop to the debug profile, which ships an unstripped ~31 MB library + if (isProductionBuild) jvmVariant = Variant.Release builds.jvm { embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget) } diff --git a/composeApp/graalvm/reachability-metadata.json b/composeApp/graalvm/reachability-metadata.json new file mode 100644 index 00000000..6d8dfc7c --- /dev/null +++ b/composeApp/graalvm/reachability-metadata.json @@ -0,0 +1,1332 @@ +{ + "reflection": [ + { + "type": "uk.co.caprica.vlcj.binding.lib.LibVlc", + "jniAccessible": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "androidx.compose.ui.platform.GlobalSnapshotManager$Registration" + }, + { + "type": "androidx.datastore.preferences.PreferencesProto$PreferenceMap", + "fields": [ + { + "name": "preferences_" + } + ] + }, + { + "type": "androidx.datastore.preferences.PreferencesProto$Value", + "fields": [ + { + "name": "valueCase_" + }, + { + "name": "value_" + } + ] + }, + { + "type": "app.cash.zipline.InterruptHandler", + "jniAccessible": true, + "methods": [ + { + "name": "poll", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "app.cash.zipline.QuickJsException", + "jniAccessible": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String", + "java.lang.String" + ] + } + ] + }, + { + "type": "app.cash.zipline.internal.bridge.CallChannel", + "jniAccessible": true, + "methods": [ + { + "name": "call", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "disconnect", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "com.sun.crypto.provider.HKDFKeyDerivation$HKDFSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [ + "javax.crypto.KDFParameters" + ] + } + ] + }, + { + "type": "com.sun.crypto.provider.HmacCore$HmacSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "com.sun.jna.CallbackReference$AttachOptions", + "jniAccessible": true, + "fields": [ + { + "name": "daemon" + }, + { + "name": "detach" + }, + { + "name": "name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "com.sun.jna.Union" + }, + { + "type": "dev.nucleusframework.window.tao.event.TaoSyntheticKeyKt$SyntheticAwtKeyEventSource$1" + }, + { + "type": "dev.nucleusframework.window.tao.event.TaoSyntheticMouseWheelEvent$source$1" + }, + { + "type": "io.ktor.client.plugins.DefaultRequest" + }, + { + "type": "io.ktor.client.plugins.HttpTimeoutConfig" + }, + { + "type": "io.ktor.client.plugins.websocket.WebSockets" + }, + { + "type": "io.ktor.server.application.PluginInstance" + }, + { + "type": "io.ktor.server.plugins.MutableOriginConnectionPoint" + }, + { + "type": "io.ktor.utils.io.ByteReadChannel" + }, + { + "type": "java.lang.Boolean", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "boolean" + ] + }, + { + "name": "booleanValue", + "parameterTypes": [ + + ] + }, + { + "name": "getBoolean", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "boolean" + ] + } + ] + }, + { + "type": "java.lang.Double", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "double" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "double" + ] + } + ] + }, + { + "type": "java.lang.Integer", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "int" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "int" + ] + } + ] + }, + { + "type": "java.lang.Module", + "methods": [ + { + "name": "getDescriptor", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "java.lang.StackWalker", + "methods": [ + { + "name": "getInstance", + "parameterTypes": [ + "java.lang.StackWalker$Option" + ] + }, + { + "name": "walk", + "parameterTypes": [ + "java.util.function.Function" + ] + } + ] + }, + { + "type": "java.lang.StackWalker$StackFrame", + "methods": [ + { + "name": "getDeclaringClass", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "kotlin.Any" + }, + { + "type": "kotlin.Boolean" + }, + { + "type": "kotlin.Cloneable" + }, + { + "type": "kotlin.Function0" + }, + { + "type": "kotlin.Function2" + }, + { + "type": "kotlin.Function3" + }, + { + "type": "kotlin.Int" + }, + { + "type": "kotlin.Long" + }, + { + "type": "kotlin.String" + }, + { + "type": "kotlin.Throwable" + }, + { + "type": "kotlin.collections.List" + }, + { + "type": "kotlin.collections.Map" + }, + { + "type": "kotlinx.atomicfu.AtomicBoolean" + }, + { + "type": "kotlinx.io.Source" + }, + { + "type": "org.freedesktop.dbus.connections.impl.DBusConnection", + "methods": [ + { + "name": "addGenericSigHandler", + "parameterTypes": [ + "org.freedesktop.dbus.matchrules.DBusMatchRule", + "org.freedesktop.dbus.interfaces.DBusSigHandler" + ] + } + ] + }, + { + "type": "org.robolectric.Robolectric" + }, + { + "type": "org.spotube.spotube.core.server.StreamProxy", + "methods": [ + { + "name": "respondWithUpstream", + "parameterTypes": [ + "io.ktor.server.application.ApplicationCall", + "io.ktor.client.statement.HttpResponse", + "java.lang.String", + "io.ktor.http.HttpMethod", + "kotlin.coroutines.Continuation" + ] + } + ] + }, + { + "type": "org.spotube.spotube.core.server.StreamProxy$respondWithUpstream$5" + }, + { + "type": "org.spotube.spotube.core.zipline.ZiplinePluginService$use$2", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "sun.net.www.protocol.http.Handler", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.net.www.protocol.https.Handler", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.security.rsa.RSASignature$SHA384withRSA", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.text.resources.cldr.FormatData_en" + }, + { + "type": "sun.text.resources.cldr.FormatData_en_US" + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_callback_t", + "jniAccessible": true, + "methods": [ + { + "name": "callback", + "parameterTypes": [ + "uk.co.caprica.vlcj.binding.internal.libvlc_event_t", + "com.sun.jna.Pointer" + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_dialog_id", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_equalizer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_manager_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_t", + "fields": [ + { + "name": "obj" + }, + { + "name": "type" + }, + { + "name": "u" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_u", + "fields": [ + { + "name": "media_duration_changed" + }, + { + "name": "media_freed" + }, + { + "name": "media_list_item_added" + }, + { + "name": "media_list_item_deleted" + }, + { + "name": "media_list_player_next_item_set" + }, + { + "name": "media_list_will_add_item" + }, + { + "name": "media_list_will_delete_item" + }, + { + "name": "media_meta_changed" + }, + { + "name": "media_parsed_changed" + }, + { + "name": "media_player_audio_device" + }, + { + "name": "media_player_audio_volume" + }, + { + "name": "media_player_buffering" + }, + { + "name": "media_player_chapter_changed" + }, + { + "name": "media_player_es_changed" + }, + { + "name": "media_player_length_changed" + }, + { + "name": "media_player_media_changed" + }, + { + "name": "media_player_pausable_changed" + }, + { + "name": "media_player_position_changed" + }, + { + "name": "media_player_scrambled_changed" + }, + { + "name": "media_player_seekable_changed" + }, + { + "name": "media_player_snapshot_taken" + }, + { + "name": "media_player_time_changed" + }, + { + "name": "media_player_title_changed" + }, + { + "name": "media_player_vout" + }, + { + "name": "media_state_changed" + }, + { + "name": "media_subitem_added" + }, + { + "name": "media_subitemtree_added" + }, + { + "name": "media_thumbnail_generated" + }, + { + "name": "renderer_discoverer_item_added" + }, + { + "name": "renderer_discoverer_item_deleted" + }, + { + "name": "vlm_media_event" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_instance_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_log_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_discoverer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_list_player_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_list_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_player_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_stats_t", + "fields": [ + { + "name": "f_demux_bitrate" + }, + { + "name": "f_input_bitrate" + }, + { + "name": "f_send_bitrate" + }, + { + "name": "i_decoded_audio" + }, + { + "name": "i_decoded_video" + }, + { + "name": "i_demux_corrupted" + }, + { + "name": "i_demux_discontinuity" + }, + { + "name": "i_demux_read_bytes" + }, + { + "name": "i_displayed_pictures" + }, + { + "name": "i_lost_abuffers" + }, + { + "name": "i_lost_pictures" + }, + { + "name": "i_played_abuffers" + }, + { + "name": "i_read_bytes" + }, + { + "name": "i_sent_bytes" + }, + { + "name": "i_sent_packets" + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_picture_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_renderer_discoverer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_renderer_item_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_duration_changed", + "fields": [ + { + "name": "new_duration" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_freed", + "fields": [ + { + "name": "md" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_item_added", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_item_deleted", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_player_next_item_set", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_will_add_item", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_will_delete_item", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_meta_changed", + "fields": [ + { + "name": "meta_type" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_parsed_changed", + "fields": [ + { + "name": "new_status" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_audio_device", + "fields": [ + { + "name": "device" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_audio_volume", + "fields": [ + { + "name": "volume" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_buffering", + "fields": [ + { + "name": "new_cache" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_chapter_changed", + "fields": [ + { + "name": "new_chapter" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_es_changed", + "fields": [ + { + "name": "i_id" + }, + { + "name": "i_type" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_length_changed", + "fields": [ + { + "name": "new_length" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_media_changed", + "fields": [ + { + "name": "md" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_pausable_changed", + "fields": [ + { + "name": "new_pausable" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_position_changed", + "fields": [ + { + "name": "new_position" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_scrambled_changed", + "fields": [ + { + "name": "new_scrambled" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_seekable_changed", + "fields": [ + { + "name": "new_seekable" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_snapshot_taken", + "fields": [ + { + "name": "filename" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_time_changed", + "fields": [ + { + "name": "new_time" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_title_changed", + "fields": [ + { + "name": "new_title" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_vout", + "fields": [ + { + "name": "new_count" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_state_changed", + "fields": [ + { + "name": "new_state" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_subitem_added", + "fields": [ + { + "name": "new_child" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_subitemtree_added", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_thumbnail_generated", + "fields": [ + { + "name": "p_thumbnail" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.renderer_discoverer_item_added", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.renderer_discoverer_item_deleted", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.vlm_media_event", + "fields": [ + { + "name": "psz_instance_name" + }, + { + "name": "psz_media_name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.support.types.size_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.support.eventmanager.NativeEventManager$EventCallback" + }, + { + "type": "uniffi.compose_app.ForeignBytesStruct", + "fields": [ + { + "name": "data" + }, + { + "name": "len" + } + ] + }, + { + "type": "uniffi.compose_app.ForeignBytesStruct$ByValue", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uniffi.compose_app.IntegrityCheckingUniffiLib" + }, + { + "type": "uniffi.compose_app.RustBufferStruct", + "fields": [ + { + "name": "capacity" + }, + { + "name": "data" + }, + { + "name": "len" + } + ] + }, + { + "type": "uniffi.compose_app.RustBufferStruct$ByValue", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uniffi.compose_app.UniffiLib" + }, + { + "type": "uniffi.compose_app.UniffiRustCallStatusStruct", + "fields": [ + { + "name": "code" + }, + { + "name": "errorBuf" + } + ] + }, + { + "type": "uniffi.compose_app.UniffiRustCallStatusStruct$ByReference" + } + ], + "resources": [ + { + "glob": "okhttp3/internal/publicsuffix/PublicSuffixDatabase.list" + } + ] +} From c767b8dcd6be315756bcbad6df910762b7ea6edf Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 17 Oct 2026 19:21:42 +0600 Subject: [PATCH 069/132] chore: update GitHub Actions to use latest versions of checkout, setup-java, setup-gradle, and upload-artifact --- .github/workflows/maven-publish.yml | 4 +- .github/workflows/spotube-release-binary.yml | 88 ++++++++++---------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index 46deed55..e32be2f7 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -10,9 +10,9 @@ jobs: runs-on: macOS-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: 'zulu' java-version: 21 diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index b132e670..afb36b23 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -38,7 +38,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout ComposeNativeWebview - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: ${{ env.COMPOSE_WEBVIEW_REPO }} ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }} @@ -46,33 +46,33 @@ jobs: path: compose-webview - name: Checkout gradle-plugin - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: ${{ env.GRADLE_PLUGIN_REPO }} token: ${{ secrets.GITHUB_TOKEN }} path: gradle-plugin - name: Checkout vlcj-bundler - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: ${{ env.VLCJ_BUNDLER_REPO }} token: ${{ secrets.GITHUB_TOKEN }} path: vlcj-bundler - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Set up Rust uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Publish compose-webview to mavenLocal working-directory: compose-webview @@ -93,7 +93,7 @@ jobs: ./gradlew publishToMavenLocal - name: Upload maven local - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: maven-local path: ~/.m2/repository/ @@ -103,16 +103,16 @@ jobs: needs: prepare-deps runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Download maven local - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: maven-local path: ~/.m2/repository/ - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -121,7 +121,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Make gradlew executable run: chmod +x gradlew @@ -154,7 +154,7 @@ jobs: cp "$APK_FILE" Spotube-android-all-arch.apk - name: Upload APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: android-apk path: Spotube-android-all-arch.apk @@ -170,10 +170,10 @@ jobs: needs: prepare-deps runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Download maven local - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: maven-local path: ~/.m2/repository/ @@ -184,7 +184,7 @@ jobs: sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -193,13 +193,13 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Make gradlew executable run: chmod +x gradlew - name: Cache GraalVM toolchain - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.gradle/nucleus/graalvm key: graalvm-${{ runner.os }}-${{ runner.arch }} @@ -215,13 +215,13 @@ jobs: cp "$RPM_FILE" Spotube-linux-x86_64.rpm - name: Upload DEB - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-deb path: Spotube-linux-x86_64.deb - name: Upload RPM - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-rpm path: Spotube-linux-x86_64.rpm @@ -237,10 +237,10 @@ jobs: needs: prepare-deps runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Download maven local - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: maven-local path: ~/.m2/repository/ @@ -251,7 +251,7 @@ jobs: sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -260,13 +260,13 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Make gradlew executable run: chmod +x gradlew - name: Cache GraalVM toolchain - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.gradle/nucleus/graalvm key: graalvm-${{ runner.os }}-${{ runner.arch }} @@ -282,13 +282,13 @@ jobs: cp "$RPM_FILE" Spotube-linux-aarch64.rpm - name: Upload DEB - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-deb-arm64 path: Spotube-linux-aarch64.deb - name: Upload RPM - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-rpm-arm64 path: Spotube-linux-aarch64.rpm @@ -304,16 +304,16 @@ jobs: needs: prepare-deps runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Download maven local - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: maven-local path: ~/.m2/repository/ - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -322,14 +322,14 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 # native-image links with MSVC - name: Set up MSVC uses: ilammy/msvc-dev-cmd@v1 - name: Cache GraalVM toolchain - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.gradle/nucleus/graalvm key: graalvm-${{ runner.os }}-${{ runner.arch }} @@ -346,7 +346,7 @@ jobs: Copy-Item $EXE_FILE.FullName Spotube-windows-x86_64-setup.exe - name: Upload EXE - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: windows-exe path: Spotube-windows-x86_64-setup.exe @@ -362,16 +362,16 @@ jobs: needs: prepare-deps runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Download maven local - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: maven-local path: ~/.m2/repository/ - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -380,13 +380,13 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Make gradlew executable run: chmod +x gradlew - name: Cache GraalVM toolchain - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.gradle/nucleus/graalvm key: graalvm-${{ runner.os }}-${{ runner.arch }} @@ -400,7 +400,7 @@ jobs: cp "$DMG_FILE" Spotube-macos-universal.dmg - name: Upload DMG - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: macos-dmg path: Spotube-macos-universal.dmg @@ -422,23 +422,23 @@ jobs: - build-macos runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - 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@v4 + uses: actions/download-artifact@v8 with: name: maven-local path: ~/.m2/repository/ - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Set release variables id: vars @@ -460,7 +460,7 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 - name: Generate release notes run: | @@ -477,7 +477,7 @@ jobs: done - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.vars.outputs.tag }} name: ${{ steps.vars.outputs.release_name }} From 75ced1285ee4b2ed5b9824bcd4d1b07a63ba4bc0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 18:04:53 +0600 Subject: [PATCH 070/132] feat(local-tracks): implement LocalFolder screen and view model for folder navigation --- .../core/audioplayer/AudioPlayer.android.kt | 4 +- .../AndroidLocalMediaDiscoveryService.kt | 230 +++++++++++------- .../core/audioplayer/AudioPlayerModels.kt | 22 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 5 +- .../org/spotube/spotube/core/di/Modules.kt | 2 + .../core/navigation/NavigationModule.kt | 13 + .../core/navigation/NavigationState.kt | 1 + .../core/remote/RemoteControlHandler.kt | 10 +- .../ui/component/cards/PlayableListTile.kt | 13 +- .../local_tracks/LibraryLocalTracksScreen.kt | 35 +-- .../LibraryLocalTracksViewModel.kt | 68 ------ .../library/local_tracks/LocalFolderScreen.kt | 35 +++ .../local_tracks/LocalFolderViewModel.kt | 66 +++++ .../modules/shell/AppExpandedPlayer.kt | 2 +- .../modules/shell/AppFloatingPlayer.kt | 2 +- .../spotube/modules/shell/AppLargePlayer.kt | 2 +- .../spotube/modules/shell/AppPlayerState.kt | 15 +- .../shell/player_queue/PlayerQueueContent.kt | 4 +- .../PlayerQueueContentViewModel.kt | 8 +- .../modules/library/MediaLibraryUiTest.kt | 6 +- 20 files changed, 332 insertions(+), 211 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index 390c834d..df055370 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -202,7 +202,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface .setArtist(artist) .setAlbumTitle(album) .apply { - if (coverURL.isNotBlank()) { + if (coverBytes != null) { + setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER) + } else if (coverURL.isNotBlank()) { setArtworkUri(android.net.Uri.parse(coverURL)) } } diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt index 0b8eb37b..f5d1d118 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt @@ -25,7 +25,18 @@ import android.os.Build import android.os.Environment import android.os.Handler import android.os.Looper +import android.graphics.BitmapFactory +import android.graphics.Bitmap import android.provider.MediaStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent import java.io.File @@ -35,14 +46,39 @@ class AndroidLocalMediaDiscoveryService( private val context: Context) : LocalMediaDiscoveryService, KoinComponent { val logger by injectLogger() - override suspend fun discoverFolders(roots: List): List { + private class TagInfo( + val title: String?, + val artists: List, + val album: String?, + val durationMs: Long, + val cover: ByteArray?) + + private class PendingTrack( + val uri: String, + val filePath: String?, + val modifiedAt: Long, + val folderPath: String, + val fallbackName: String, + val mediaStoreArtists: List, + val mediaStoreAlbum: String?, + val mediaStoreDurationMs: Long) + + // Parsing tags + cover with lofty is the expensive part, so keep results across rescans. + private val tagCache = ConcurrentHashMap() + + override suspend fun discoverFolders(roots: List): List = withContext(Dispatchers.IO) { + discoverFoldersBlocking(roots) + } + + private suspend fun discoverFoldersBlocking(roots: List): List { logger.i { "discoverFolders: start roots=${roots.size}" } if (!hasReadPermission()) { logger.w { "discoverFolders: missing media permission, returning empty result" } return emptyList() } - val tracksByFolder = linkedMapOf>() + val pending = mutableListOf() + val knownFilePaths = hashSetOf() val resolver = context.contentResolver val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI var totalRows = 0 @@ -58,7 +94,9 @@ class AndroidLocalMediaDiscoveryService( MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) { + MediaStore.Audio.Media.MIME_TYPE, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.DATE_MODIFIED) + if (Build.VERSION.SDK_INT >= 29) { arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) } else { emptyArray() @@ -77,6 +115,8 @@ class AndroidLocalMediaDiscoveryService( val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE) val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH) + val dataCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val modifiedCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED) logger.d { "discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)" @@ -118,20 +158,23 @@ class AndroidLocalMediaDiscoveryService( .takeUnless { it.equals("", true) } val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() } val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L) + val filePath = if (dataCol >= 0) cursor.getString(dataCol)?.takeIf { it.isNotBlank() } else null + val modifiedAt = if (modifiedCol >= 0) cursor.getLong(modifiedCol) else 0L + filePath?.let(knownFilePaths::add) val name = rawTitle.ifBlank { displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName } - val track = LocalMediaTrack( - path = uri, - name = name.ifBlank { "Unknown track" }, - artists = listOfNotNull(artist), - durationMs = durationMs, - album = album, - coverBytes = null) - - tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) + pending += PendingTrack( + uri = uri, + filePath = filePath, + modifiedAt = modifiedAt, + folderPath = folderPath, + fallbackName = name.ifBlank { "Unknown track" }, + mediaStoreArtists = listOfNotNull(artist), + mediaStoreAlbum = album, + mediaStoreDurationMs = durationMs) acceptedRows += 1 } } ?: run { @@ -142,20 +185,13 @@ class AndroidLocalMediaDiscoveryService( logger.w { "discoverFolders: MediaStore query returned null cursor" } } - val mediaStoreFolders = tracksByFolder.entries - .map { (folderPath, tracks) -> - LocalMediaFolder( - path = folderPath, - name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) - } - .sortedBy { it.name.lowercase(Locale.getDefault()) } + // Files MediaStore already indexed must not be listed a second time by the filesystem scan. + discoverFileSystemFallback(roots, knownFilePaths, pending) - val fallbackFolders = discoverFileSystemFallback(roots) - val merged = mergeFolders(mediaStoreFolders, fallbackFolders) + val merged = buildFolders(pending) logger.i { - "discoverFolders: done folders=${merged.size} mediaStoreFolders=${mediaStoreFolders.size} fallbackFolders=${fallbackFolders.size} tracks=$acceptedRows totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows" + "discoverFolders: done folders=${merged.size} mediaStoreTracks=$acceptedRows tracks=${pending.size} totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows" } if (merged.isNotEmpty()) { @@ -221,56 +257,109 @@ class AndroidLocalMediaDiscoveryService( return extension in SUPPORTED_EXTENSIONS } - private fun discoverFileSystemFallback(roots: List): List { + private fun discoverFileSystemFallback( + roots: List, + knownFilePaths: Set, + out: MutableList) { val candidateRoots = resolveCandidateRoots(roots) if (candidateRoots.isEmpty()) { logger.d { "discoverFileSystemFallback: no candidate roots" } - return emptyList() + return } - - logger.d { - "discoverFileSystemFallback: scanning roots=${candidateRoots.joinToString()}" - } - - val tracksByFolder = linkedMapOf>() - var scannedFiles = 0 - var acceptedFiles = 0 + val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/" + val seen = hashSetOf() + var added = 0 candidateRoots.forEach { root -> scanDirectory(root) { file -> - scannedFiles += 1 - val extension = file.extension.lowercase(Locale.getDefault()) - if (extension !in SUPPORTED_EXTENSIONS) return@scanDirectory - acceptedFiles += 1 + if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory + val absolute = file.absolutePath + if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory - val folderPath = file.parentFile?.absolutePath ?: "Unknown" - val name = file.nameWithoutExtension.ifBlank { file.name } - val track = LocalMediaTrack( - path = file.absolutePath, - name = name, - artists = emptyList(), - durationMs = 0L, - album = null, - coverBytes = null) - - tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) + // Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge. + val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/" + val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent + out += PendingTrack( + uri = absolute, + filePath = absolute, + modifiedAt = file.lastModified() / 1000, + folderPath = folderPath, + fallbackName = file.nameWithoutExtension.ifBlank { file.name }, + mediaStoreArtists = emptyList(), + mediaStoreAlbum = null, + mediaStoreDurationMs = 0L) + added += 1 } } + logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" } + } - val folders = tracksByFolder.entries - .map { (folderPath, tracks) -> + private suspend fun buildFolders(pending: List): List { + val permits = Semaphore(4) + val tracks = coroutineScope { + pending.map { item -> + async(Dispatchers.IO) { + val tag = permits.withPermit { readTags(item) } + item.folderPath to LocalMediaTrack( + path = item.uri, + name = tag?.title?.takeIf { it.isNotBlank() } ?: item.fallbackName, + artists = tag?.artists?.takeIf { it.isNotEmpty() } ?: item.mediaStoreArtists, + durationMs = tag?.durationMs?.takeIf { it > 0 } ?: item.mediaStoreDurationMs, + album = tag?.album?.takeIf { it.isNotBlank() } ?: item.mediaStoreAlbum, + coverBytes = tag?.cover) + } + }.awaitAll() + } + return tracks.groupBy({ it.first }, { it.second }) + .map { (path, list) -> LocalMediaFolder( - path = folderPath, - name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) + path = path, + name = normalizeFolderName(path), + tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } + } - logger.i { - "discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}" + private fun readTags(item: PendingTrack): TagInfo? { + val path = item.filePath ?: return null + val key = "$path:${item.modifiedAt}" + tagCache[key]?.let { return it } + val file = File(path) + if (!file.isFile || !file.canRead()) return null + val info = try { + val meta = uniffi.compose_app.readAudioMetadata(path) + TagInfo( + title = meta.title, + artists = meta.artists, + album = meta.album, + durationMs = meta.durationMs, + cover = meta.coverBytes?.let(::thumbnail)) + } catch (t: Throwable) { + logger.d { "readTags: failed path=$path reason=${t.message}" } + null } + if (info != null) tagCache[key] = info + return info + } - return folders + // Embedded covers can be several MB; the cache is serialised as JSON, so keep a small JPEG. + private fun thumbnail(bytes: ByteArray): ByteArray? { + if (bytes.isEmpty()) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (bounds.outWidth / (sample * 2) >= THUMBNAIL_SIZE && bounds.outHeight / (sample * 2) >= THUMBNAIL_SIZE) sample *= 2 + val decoded = BitmapFactory.decodeByteArray( + bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null + val scale = THUMBNAIL_SIZE.toFloat() / minOf(decoded.width, decoded.height) + val bitmap = if (scale < 1f) Bitmap.createScaledBitmap( + decoded, (decoded.width * scale).toInt().coerceAtLeast(1), + (decoded.height * scale).toInt().coerceAtLeast(1), true) else decoded + return ByteArrayOutputStream().use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) + it.toByteArray() + } } private fun resolveCandidateRoots(roots: List): List { @@ -309,35 +398,8 @@ class AndroidLocalMediaDiscoveryService( } } - private fun mergeFolders( - mediaStoreFolders: List, - fallbackFolders: List): List { - if (fallbackFolders.isEmpty()) return mediaStoreFolders - if (mediaStoreFolders.isEmpty()) return fallbackFolders - - val merged = linkedMapOf>() - val seenTrackPaths = mutableSetOf() - - (mediaStoreFolders + fallbackFolders).forEach { folder -> - val bucket = merged.getOrPut(folder.path) { mutableListOf() } - folder.tracks.forEach { track -> - if (seenTrackPaths.add(track.path)) { - bucket.add(track) - } - } - } - - return merged.entries - .map { (path, tracks) -> - LocalMediaFolder( - path = path, - name = normalizeFolderName(path), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) - } - .sortedBy { it.name.lowercase(Locale.getDefault()) } - } - companion object { + private const val THUMBNAIL_SIZE = 160 private val SUPPORTED_EXTENSIONS = setOf( "mp3", "m4a", diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt index 9d6e6a2d..8840f8b3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt @@ -30,7 +30,8 @@ data class MediaItem( val duration: Duration, val coverURL: String, val url: String, - val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + val coverBytes: ByteArray? = null) enum class LoopState { NONE, ONE, ALL; @@ -53,13 +54,28 @@ sealed interface QueueEntry { val url: String val addedBy: String + /** Remote artwork URL, for consumers that can only take a link (remote control, media session). */ + val artworkUrl: String? + + /** + * Single source of truth for a queue entry's artwork, in a form Coil can load directly: + * a URL `String` for streaming tracks, embedded cover `ByteArray` for local ones. + * Every UI surface (players, queue, lists) must use this instead of reading thumbnails itself. + */ + val artworkModel: Any? + get() = (this as? LocalTrack)?.coverBytes?.takeIf { it.isNotEmpty() } ?: artworkUrl + @Serializable @SerialName("streaming") data class StreamingTrack( val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, - override val addedBy: String = "") : QueueEntry + override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? + get() = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + } @Serializable @SerialName("local") @@ -71,6 +87,8 @@ sealed interface QueueEntry { val coverBytes: ByteArray?, override val url: String, override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? get() = null + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 4fc6e165..fb283055 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -463,7 +463,7 @@ class DeviceAudioPlayerQueue( artist = track.artists.joinToString(", ") { artist -> artist.name }, album = track.album?.title.orEmpty(), duration = track.durationMs.milliseconds, - coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), + coverURL = artworkUrl.orEmpty(), url = resolvedUrl, protocol = protocol) } @@ -476,7 +476,8 @@ class DeviceAudioPlayerQueue( album = album.orEmpty(), duration = duration.milliseconds, coverURL = "", - url = url) + url = url, + coverBytes = coverBytes?.takeIf { it.isNotEmpty() }) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index bcc986dc..cd8e2742 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -60,6 +60,7 @@ import org.spotube.spotube.modules.library.LibraryState import org.spotube.spotube.modules.library.album.LibraryAlbumsViewModel import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator @@ -133,6 +134,7 @@ val sharedModules = module { viewModelOf(::LibraryAlbumsViewModel) viewModelOf(::LibraryArtistsViewModel) viewModelOf(::LibraryLocalTracksViewModel) + viewModel { (folderPath: String) -> LocalFolderViewModel(folderPath, get(), get()) } // Plugin system singleOf(::PluginManager) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt index 93c707c1..aef9d397 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -26,6 +26,8 @@ import org.spotube.spotube.modules.devices.RemoteControlScreen import org.spotube.spotube.modules.jam.JamScreen import org.spotube.spotube.modules.home.HomeScreen import org.spotube.spotube.modules.library.LibraryScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel import org.spotube.spotube.modules.lyrics.LyricsScreen import org.spotube.spotube.modules.playlist.PlaylistScreen import org.spotube.spotube.modules.plugin.PluginScreen @@ -96,6 +98,9 @@ sealed interface Routes : NavKey { @Serializable data object Welcome : Routes + + @Serializable + data class LocalFolder(val folderPath: String) : Routes } @OptIn(KoinExperimentalAPI::class) @@ -115,6 +120,14 @@ val navigationModule = module { // Not guarded as a whole: Local tracks and Downloads work without a plugin. LibraryScreen() } + navigation { + LocalFolderScreen( + viewModel = koinViewModel( + key = it.folderPath, + parameters = { parametersOf(it.folderPath) } + ), + onBack = { get().pop() }) + } navigation { SettingsScreen(settingsViewModel = koinViewModel()) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index bd35c0ff..f92c91d5 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -64,6 +64,7 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Devices::class, Routes.Devices.serializer()) subclass(Routes.Jam::class, Routes.Jam.serializer()) subclass(Routes.Welcome::class, Routes.Welcome.serializer()) + subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt index 0fe817eb..fc5a672d 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt @@ -354,7 +354,7 @@ class RemoteControlHandler( currentTrackTitle = current?.titleOrNull(), currentTrackArtists = current?.artistsOrNull(), currentTrackAlbum = current?.albumOrNull(), - currentTrackCoverUrl = current?.coverUrlOrNull()) + currentTrackCoverUrl = current?.artworkUrl) val text = json.encodeToString(RemoteControlEvent.serializer(), state) session.send(Frame.Text(text)) } @@ -395,7 +395,7 @@ class RemoteControlHandler( title = track.title, artists = track.artists.joinToString(", ") { artist -> artist.name }, album = track.album?.title, - coverUrl = coverUrlOrNull(), + coverUrl = artworkUrl, durationMs = track.durationMs) is QueueEntry.LocalTrack -> RemoteQueueEntry( @@ -427,12 +427,6 @@ class RemoteControlHandler( is QueueEntry.StreamingTrack -> track.album?.title is QueueEntry.LocalTrack -> album } - - private fun QueueEntry.coverUrlOrNull(): String? = when (this) { - is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url - ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url - is QueueEntry.LocalTrack -> null - } } @Serializable diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt index 8b8edd2a..f01bcb0f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt @@ -61,7 +61,8 @@ fun PlayableListTile( placeholderIcon: ImageVector = Iconsax.IconsaxMusic, artwork: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, - isBlacklisted: Boolean = false + isBlacklisted: Boolean = false, + compact: Boolean = false ) { val colors = MillenniumTheme.colors MillenniumListTile( @@ -73,12 +74,12 @@ fun PlayableListTile( borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border, titleForeground = if (isBlacklisted) colors.danger else null, borderWidth = 1.dp, - contentPadding = PaddingValues(12.dp), - minimumHeight = 76.dp, + contentPadding = PaddingValues(if (compact) 8.dp else 12.dp), + minimumHeight = if (compact) 52.dp else 76.dp, leading = { MediaArtwork( imageModel, title, - Modifier.size(52.dp).sharedElementOrNone( + Modifier.size(if (compact) 36.dp else 52.dp).sharedElementOrNone( sharedElementKey, LocalSharedTransitionScope.current, LocalAnimatedVisibilityScope.current @@ -114,7 +115,7 @@ fun PlayableListTile( if (onAddToQueue != null && !isPlaying) { MillenniumIconButton( onClick = onAddToQueue, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Secondary ) { MillenniumIcon( @@ -126,7 +127,7 @@ fun PlayableListTile( if (onPlay != null) { MillenniumIconButton( onClick = onPlay, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Primary ) { MillenniumIcon( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index d7bcb401..e379c8db 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -47,7 +47,10 @@ import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.pluralStringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumIcon @@ -63,15 +66,18 @@ import spotube.composeapp.generated.resources.* fun LibraryLocalTracksScreen( modifier: Modifier = Modifier, viewModel: LibraryLocalTracksViewModel = koinViewModel(), - foldersViewModel: LocalMediaFoldersViewModel = koinViewModel() + foldersViewModel: LocalMediaFoldersViewModel = koinViewModel(), + navigationCommands: NavigationCommands = koinInject() ) { val state by viewModel.state.collectAsStateWithLifecycle() val permission = rememberLocalMediaPermissionState() // Platform permission events only; the ViewModel decides whether/when to index media. LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } LocalTracksContent( - state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, - viewModel::playCurrentFolder, permission.requestPermission, modifier, + state, + onOpenFolder = { navigationCommands.navigateTo(Routes.LocalFolder(it)) }, + onPlayTrack = {}, onNavigateUp = {}, onPlayFolder = {}, + onRequestPermission = permission.requestPermission, modifier = modifier, onManageFolders = foldersViewModel::open ) LocalMediaFoldersDialogHost(foldersViewModel) @@ -120,7 +126,7 @@ internal fun LocalTracksContent( state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(bottom = inset + 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(state.visibleFolders, key = { it.path }) { folder -> LocalFolderRow(folder) { onOpenFolder(folder.path) } @@ -130,7 +136,7 @@ internal fun LocalTracksContent( state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn( Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(bottom = inset + 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(state.visibleTracks, key = { it.path }) { track -> PlayableListTile( @@ -139,6 +145,7 @@ internal fun LocalTracksContent( imageModel = track.artwork, onClick = { onPlayTrack(track.path) }, onPlay = { onPlayTrack(track.path) }, + compact = true, trailingContent = { track.durationLabel?.let { Text( @@ -195,7 +202,7 @@ internal fun LocalTracksBreadcrumb( ) { val backDescription = stringResource(Res.string.library_local_back) Row( - modifier.fillMaxWidth().height(32.dp), + modifier.fillMaxWidth().height(40.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -268,22 +275,22 @@ internal fun LocalTracksBreadcrumb( if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp) if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton( onClick = onManageFolders, - modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( Iconsax.IconsaxFolderAdd, stringResource(Res.string.library_local_add_folder), - modifier = Modifier.size(14.dp) + modifier = Modifier.size(22.dp) ) } if (state.currentFolderPath != null) MillenniumIconButton( onClick = onPlayFolder, enabled = state.canPlayFolder, - modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( Iconsax.IconsaxPlay, stringResource(Res.string.library_local_play_folder), - modifier = Modifier.size(14.dp) + modifier = Modifier.size(22.dp) ) } } @@ -297,13 +304,13 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { shape = RoundedCornerShape(8.dp), borderColor = MillenniumTheme.colors.border, borderWidth = 1.dp, - contentPadding = PaddingValues(12.dp), - minimumHeight = 76.dp, + contentPadding = PaddingValues(8.dp), + minimumHeight = 52.dp, leading = { MediaArtwork( folder.artwork, folder.name, - Modifier.size(52.dp), + Modifier.size(36.dp), placeholderIcon = Iconsax.IconsaxFolderOpen ) }, @@ -328,7 +335,7 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { MillenniumIcon( FeatherIcons.ChevronRight, null, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(16.dp) ) } ) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index 706052da..b28db0c6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -19,8 +19,6 @@ package org.spotube.spotube.modules.library.local_tracks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import org.spotube.spotube.core.audioplayer.AudioPlayerQueue -import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.modules.library.LibraryState import org.spotube.spotube.modules.library.LibraryTab import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder @@ -120,7 +118,6 @@ private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean = class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, - private val audioPlayerQueue: AudioPlayerQueue, private val libraryState: LibraryState ) : ViewModel() { private val requiresPermission = getPlatform().type == PlatformType.Android @@ -155,14 +152,6 @@ class LibraryLocalTracksViewModel( libraryState.setQuery(LibraryTab.LocalTracks, query) } - fun openFolder(folderPath: String) { - _state.update { it.copy(currentFolderPath = folderPath) } - } - - fun navigateUp() { - _state.update { it.copy(currentFolderPath = null) } - } - fun onPermissionChanged(granted: Boolean) { val accessible = !requiresPermission || granted val wasAccessible = _state.value.canAccessMedia @@ -170,10 +159,6 @@ class LibraryLocalTracksViewModel( if (accessible && !wasAccessible) refresh() } - fun playCurrentFolder() { - _state.value.currentFolderPath?.let(::playFolder) - } - fun refresh() { if (!_state.value.canAccessMedia) return viewModelScope.launch { @@ -191,41 +176,6 @@ class LibraryLocalTracksViewModel( } } - fun playFolder(folderPath: String) { - if (!_state.value.canAccessMedia) return - val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return - val tracks = filterTracks(folder.tracks, state.value.query) - if (tracks.isEmpty()) return - - viewModelScope.launch { - audioPlayerQueue.load( - entries = tracks.map { it.toQueueEntry() }, - autoPlay = true, - startPosition = 0, - collectionEntry = null - ) - } - } - - fun playTrack(trackPath: String) { - if (!_state.value.canAccessMedia) return - val folder = currentFolder() ?: return - val tracks = filterTracks(folder.tracks, state.value.query) - val selectedIndex = tracks.indexOfFirst { it.path == trackPath } - if (selectedIndex < 0) return - - viewModelScope.launch { - audioPlayerQueue.load( - entries = tracks.map { it.toQueueEntry() }, - autoPlay = true, - startPosition = selectedIndex, - collectionEntry = null - ) - } - } - - private fun currentFolder(): LocalMediaFolder? = state.value.currentFolder - private fun refreshIfEmpty() { if (_state.value.folders.isNotEmpty()) return refresh() @@ -239,22 +189,4 @@ class LibraryLocalTracksViewModel( } } } - - private fun filterTracks(tracks: List, query: String): List { - val normalizedQuery = query.trim() - if (normalizedQuery.isBlank()) return tracks - - return tracks.filter { localTrackMatches(it, normalizedQuery) } - } - - private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack { - return QueueEntry.LocalTrack( - name = name, - artists = artists, - duration = durationMs, - album = album, - coverBytes = coverBytes, - url = path - ) - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt new file mode 100644 index 00000000..d3583913 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt @@ -0,0 +1,35 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.ApplicationMainBar + +@Composable +fun LocalFolderScreen(viewModel: LocalFolderViewModel, onBack: () -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + Column { + ApplicationMainBar( + backButton = true, + title = { Text(state.currentFolder?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) } + ) + LocalTracksContent( + state, + onOpenFolder = {}, + onPlayTrack = viewModel::playTrack, + onNavigateUp = onBack, + onPlayFolder = viewModel::playFolder, + onRequestPermission = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt new file mode 100644 index 00000000..01e4f6d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack + +class LocalFolderViewModel( + private val folderPath: String, + coordinator: LocalMediaLibraryCoordinator, + private val audioPlayerQueue: AudioPlayerQueue +) : ViewModel() { + val state: StateFlow = coordinator.cacheState + .map { cache -> + localTracksUiState( + LibraryLocalTracksState(folders = cache.folders, currentFolderPath = folderPath), + "" + ) + } + .stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), + localTracksUiState(LibraryLocalTracksState(currentFolderPath = folderPath), "") + ) + + fun playFolder() = play(0) + + fun playTrack(trackPath: String) { + val index = tracks().indexOfFirst { it.path == trackPath } + if (index >= 0) play(index) + } + + private fun tracks(): List = + state.value.currentFolder?.tracks.orEmpty() + + private fun play(startPosition: Int) { + val tracks = tracks() + if (tracks.isEmpty()) return + viewModelScope.launch { + audioPlayerQueue.load( + entries = tracks.map { + QueueEntry.LocalTrack( + name = it.name, + artists = it.artists, + duration = it.durationMs, + album = it.album, + coverBytes = it.coverBytes, + url = it.path + ) + }, + autoPlay = true, + startPosition = startPosition, + collectionEntry = null + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt index ddbdd2b5..a95bb526 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt @@ -211,7 +211,7 @@ fun AppExpandedPlayer( val snackbarHostState = remember { MillenniumSnackbarHostState() } val albumArtModifier = rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + val coverModel = playerUiState.coverModel var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } val moreOptionsSheetState = rememberModalBottomSheetState( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt index 9f9ebca4..fd64f559 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt @@ -131,7 +131,7 @@ internal fun FloatingPlayerContent( onPlayPause: () -> Unit, heartButton: @Composable () -> Unit ) { - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + val coverModel = playerUiState.coverModel Box( modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt index 25f016cd..3490f2fa 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt @@ -164,7 +164,7 @@ fun AppLargePlayer( var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + val coverModel = playerUiState.coverModel PlayerOptionDialogs(viewModel = playerOptionsViewModel) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt index b2b8fd0a..fec5626a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt @@ -45,7 +45,6 @@ internal data class PlayerUiState( val artists: String = "No-op artists", val artistList: List = emptyList(), val album: String = "", - val coverUrl: String = "", val position: Duration = 0.milliseconds, val actualDuration: Duration = 0.milliseconds, val metadataDuration: Duration = 0.milliseconds, @@ -55,6 +54,8 @@ internal data class PlayerUiState( val volume: Float = 0f ) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING + + val coverModel: Any? get() = currentQueueEntry?.artworkModel val displayDuration: Duration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration val seekDuration: Duration @@ -72,7 +73,7 @@ internal data class PlayerUiState( queue=${queue.map { it.displayTitle() }}, currentQueueEntry=${currentQueueEntry?.displayTitle()}, title='$title', artists='$artists', - album='$album', coverUrl='$coverUrl', + album='$album', coverModel=${coverModel?.let { it::class.simpleName }}, position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration, playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume ) @@ -105,7 +106,6 @@ internal fun rememberPlayerUiState( artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists }, artistList = currentQueueEntry?.displayArtistList().orEmpty(), album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(), - coverUrl = currentQueueEntry?.coverUrl().orEmpty(), position = position, actualDuration = duration, metadataDuration = metadataDuration, @@ -149,15 +149,6 @@ private fun QueueEntry.displayAlbum(unknownAlbum: String): String { } } -private fun QueueEntry.coverUrl(): String { - return when (this) { - is QueueEntry.StreamingTrack -> (track.album?.thumbnails - ?: track.thumbnails)?.firstOrNull()?.url.orEmpty() - - is QueueEntry.LocalTrack -> "" - } -} - private fun QueueEntry.durationInMilliseconds(): Long { return when (this) { is QueueEntry.StreamingTrack -> track.durationMs diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 648d1526..6b3181ac 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -350,9 +350,9 @@ private fun QueueItemRow( .background(AppTheme.colors.surfaceVariant), contentAlignment = Alignment.Center ) { - if (item.imageUrl != null) { + if (item.artwork != null) { AsyncImage( - model = item.imageUrl, + model = item.artwork, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 1738cdf8..8da73f4c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -39,7 +39,7 @@ data class QueueItemUi( val subtitle: String, val durationLabel: String, val isCurrent: Boolean, - val imageUrl: String?, + val artwork: Any?, val originalIndex: Int, /** Participant who added this item to the jam queue, if any. */ val addedByParticipant: JamParticipant? = null) @@ -77,22 +77,18 @@ class PlayerQueueContentViewModel( val title: String val subtitle: String val durationMs: Long - val imageUrl: String? when (entry) { is QueueEntry.StreamingTrack -> { title = entry.track.title subtitle = entry.track.artists.joinToString(", ") { it.name } durationMs = entry.track.durationMs - imageUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url - ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url } is QueueEntry.LocalTrack -> { title = entry.name subtitle = entry.artists.joinToString(", ") durationMs = entry.duration - imageUrl = null } } @@ -104,7 +100,7 @@ class PlayerQueueContentViewModel( subtitle = subtitle, durationLabel = durationMs.toDurationLabel(), isCurrent = index == currentIndex, - imageUrl = imageUrl, + artwork = entry.artworkModel, originalIndex = index, addedByParticipant = addedByParticipant) } diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt index f8fdef07..05d2aa04 100644 --- a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt @@ -158,7 +158,7 @@ class MediaLibraryUiTest { } @Test - fun breadcrumbIsThinAndItsParentLinkNavigatesUp() { + fun breadcrumbIsCompactAndItsParentLinkNavigatesUp() { var height = 0 var navigatedUp = 0 val state = localTracksUiState( @@ -167,7 +167,7 @@ class MediaLibraryUiTest { currentFolderPath = "/music" ), "" ) - ImageComposeScene(360, 40) { + ImageComposeScene(360, 48) { SpotubeTheme(UserSettings(theme = Theme.DARK)) { LocalTracksBreadcrumb( state, @@ -177,7 +177,7 @@ class MediaLibraryUiTest { } }.use { scene -> repeat(6) { scene.render().close() } - assertEquals(32, height) + assertEquals(40, height) val parent = allNodes(scene).first { it.config.getOrNull(SemanticsActions.OnClick) != null && it.config.getOrNull(SemanticsProperties.ContentDescription) From 17f2068950b8b9cb370bd41a8b9f5dc55a369a48 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 18:31:51 +0600 Subject: [PATCH 071/132] feat(navigation): implement fade-through and stack transition animations for navigation --- .../spotube/core/navigation/NavTransitions.kt | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt index d6483ffb..4a793b51 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt @@ -25,6 +25,7 @@ import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -33,7 +34,10 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.Scene private const val TRANSITION_DURATION_MILLIS = 300 -private const val SLIDE_FRACTION = 0.08f +private const val FADE_THROUGH_OUT_MILLIS = 90 +private const val FADE_THROUGH_SCALE = 0.94f +private const val SLIDE_FRACTION = 0.25f +private const val BEHIND_SLIDE_FRACTION = 0.1f private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) // NavEntry.contentKey defaults to key.toString() @@ -49,28 +53,49 @@ private fun Scene.tabRoot(): Any? = private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean = initialState.tabRoot() != targetState.tabRoot() -/** Material shared-axis (X) style: a short horizontal slide combined with a cross-fade. */ -private fun AnimatedContentTransitionScope>.sharedAxisX( +/** Material fade-through: the old tab fades out, the new one fades in while scaling up slightly. */ +private fun fadeThrough(): ContentTransform { + val enter = fadeIn( + tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate) + ) + scaleIn( + tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate), + initialScale = FADE_THROUGH_SCALE + ) + return enter togetherWith fadeOut(tween(FADE_THROUGH_OUT_MILLIS)) +} + +/** + * Pushed/popped pages inside a tab: the page on top slides + fades while the page behind + * drifts slightly the other way. On pop the leaving page must stay on top (negative target + * z-index), otherwise the page being revealed is drawn over it and its exit is never seen. + */ +private fun AnimatedContentTransitionScope>.stackTransition( forward: Boolean ): ContentTransform { - if (isTabSwitch()) return ContentTransform(EnterTransition.None, ExitTransition.None) + if (isTabSwitch()) return fadeThrough() - val direction = if (forward) 1 else -1 - val slideSpec = tween( - TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate - ) - val enter = slideInHorizontally(slideSpec) { (it * SLIDE_FRACTION).toInt() * direction } + - fadeIn(tween(TRANSITION_DURATION_MILLIS / 2, delayMillis = TRANSITION_DURATION_MILLIS / 6)) - val exit = slideOutHorizontally(slideSpec) { -(it * SLIDE_FRACTION).toInt() * direction } + - fadeOut(tween(TRANSITION_DURATION_MILLIS / 3)) - return enter togetherWith exit + val slide = tween(TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate) + val enter: EnterTransition + val exit: ExitTransition + if (forward) { + enter = slideInHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } + + fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + exit = slideOutHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } + + fadeOut(tween(TRANSITION_DURATION_MILLIS / 2)) + } else { + enter = slideInHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } + + fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + exit = slideOutHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } + + fadeOut(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + } + return ContentTransform(enter, exit, targetContentZIndex = if (forward) 0f else -1f) } val navPushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = - { sharedAxisX(forward = true) } + { stackTransition(forward = true) } val navPopTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = - { sharedAxisX(forward = false) } + { stackTransition(forward = false) } val navPredictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = - { sharedAxisX(forward = false) } + { stackTransition(forward = false) } From 1233967982e0bf1016c85e6dfb48dcda064c31d6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 19:23:18 +0600 Subject: [PATCH 072/132] chore: update contact information and library version in project files --- CONTRIBUTION.md | 2 +- PRIVACY_POLICY.md | 2 +- gradle/libs.versions.toml | 2 +- plugin_interfaces/build.gradle.kts | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index dc1cebaf..94f053dd 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -32,7 +32,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents This project and everyone participating in it is governed by the [Spotube Code of Conduct](https://github.com/team-spotube/spotube/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior -to krtirtho@gmail.com. +to team.spotube@proton.me. ## I Have a Question diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 274abdc8..e00abc7c 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -59,4 +59,4 @@ This policy is effective as of 2022-04-01 **Contact Us** -If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at krtirtho@gmail.com. \ No newline at end of file +If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at team.spotube@proton.me \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4186c354..5d86ffc8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" -millennium = "1.0.0" +millennium = "0.1.0" mqttClient = "2.1.1" mqttBuffer = "6.30.8" multiplatformMarkdownRenderer = "0.45.0" diff --git a/plugin_interfaces/build.gradle.kts b/plugin_interfaces/build.gradle.kts index 4528ce16..266b08b7 100644 --- a/plugin_interfaces/build.gradle.kts +++ b/plugin_interfaces/build.gradle.kts @@ -115,9 +115,10 @@ mavenPublishing { } developers { developer { - id = "KRTirtho" - name = "Kingkor Roy Tirtho" + id = "team-spotube" + name = "Team Spotube" url = "https://github.com/team-spotube/" + email = "team.spotube@proton.me" } } scm { From 03aaffeca8f6a982e5f98e4f865e79c635b73953 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 20:07:20 +0600 Subject: [PATCH 073/132] chore: update spotube gradle plugin version --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d86ffc8..c441d82a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" ksp = "2.3.5" -spotube-gradle = "0.1.1" +spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" vlcjBundler = "0.1.0" From 33a8fdb8c2c5aa0c7ccfb16416496e5a1fce5266 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 20:33:51 +0600 Subject: [PATCH 074/132] cd: add GitHub token for API calls and set up Android SDK in workflow --- .github/workflows/spotube-release-binary.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index afb36b23..e58b6396 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -31,6 +31,8 @@ env: COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin + # Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit on shared runners + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: prepare-deps: @@ -239,6 +241,10 @@ jobs: steps: - uses: actions/checkout@v7 + # The arm64 runner image ships without an Android SDK, but :composeApp configuration requires one + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + - name: Download maven local uses: actions/download-artifact@v8 with: From 613159e26ece7f6e42000ca739ed87dc0620af03 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 20:47:51 +0600 Subject: [PATCH 075/132] chore: add --stacktrace option to gradlew command for better debugging --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index e58b6396..9a98c3c8 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -278,7 +278,7 @@ jobs: key: graalvm-${{ runner.os }}-${{ runner.arch }} - name: Package Linux - run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm --stacktrace -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} - name: Rename Linux artifacts run: | From b333c299d1de4538346b897301835d123956eaba Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 22:58:57 +0600 Subject: [PATCH 076/132] chore: update Android SDK setup to include NDK for composeApp configuration --- .github/workflows/spotube-release-binary.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 9a98c3c8..2e566f82 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -241,9 +241,12 @@ jobs: steps: - uses: actions/checkout@v7 - # The arm64 runner image ships without an Android SDK, but :composeApp configuration requires one + # The arm64 runner image ships without an Android SDK/NDK, but :composeApp configuration needs both + # (Gobley's cargo plugin resolves the NDK directory for the Android Rust targets) - name: Set up Android SDK uses: android-actions/setup-android@v4 + with: + packages: tools platform-tools ndk;28.2.13676358 - name: Download maven local uses: actions/download-artifact@v8 From 2f7eb742cfc8087be6f54426d29c8af03697a499 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 23:49:34 +0600 Subject: [PATCH 077/132] chore: disable Android Rust build tasks on Linux arm64 hosts --- composeApp/build.gradle.kts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5fd468bf..c45cb912 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -543,3 +543,14 @@ cargo { embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget) } } + +// Google only ships x86_64 Linux NDK toolchains, so on a Linux arm64 host the Android Rust targets can never +// link (clang: "Exec format error"). Desktop packaging doesn't need them; Android is built on x86_64 runners. +run { + val isLinuxArm64Host = System.getProperty("os.name").lowercase().contains("linux") && + System.getProperty("os.arch").lowercase() in setOf("aarch64", "arm64") + if (isLinuxArm64Host) { + tasks.matching { it.name.startsWith("cargoBuildAndroid") || it.name.startsWith("rustUpTargetAddAndroid") } + .configureEach { enabled = false } + } +} From d4d8ebd853e397a1068d13dce6eccc2d79994534 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 19 Oct 2026 10:34:46 +0600 Subject: [PATCH 078/132] chore: configure UniFFI to generate bindings from host library on Linux arm64 --- composeApp/build.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c45cb912..1852a641 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -550,6 +550,12 @@ run { val isLinuxArm64Host = System.getProperty("os.name").lowercase().contains("linux") && System.getProperty("os.arch").lowercase() in setOf("aarch64", "arm64") if (isLinuxArm64Host) { + // Gobley generates the UniFFI bindings from the first Android build's library by default; use the host's. + uniffi { + generateFromLibrary { + build = GobleyHost.current.rustTarget + } + } tasks.matching { it.name.startsWith("cargoBuildAndroid") || it.name.startsWith("rustUpTargetAddAndroid") } .configureEach { enabled = false } } From ced26462a907331deb45729f50b68509b3422a51 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 19 Oct 2026 11:16:29 +0600 Subject: [PATCH 079/132] chore: update GitHub Actions workflow for release process and improve permissions --- .github/workflows/spotube-release-binary.yml | 73 +++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 2e566f82..db218921 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -22,17 +22,20 @@ on: type: boolean default: false +# Read-only by default; only create-release gets write access +permissions: + contents: read + +# Never cancel a running release when another is dispatched, and let stable and nightly run side by side concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }} + cancel-in-progress: false env: COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin - # Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit on shared runners - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: prepare-deps: @@ -103,6 +106,7 @@ jobs: build-android: name: Android needs: prepare-deps + timeout-minutes: 120 runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -129,17 +133,23 @@ jobs: run: chmod +x gradlew - name: Decode keystore + env: + KEYSTORE: ${{ secrets.KEYSTORE }} run: | - echo '${{ secrets.KEYSTORE }}' | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" + echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" - name: Configure Android signing working-directory: composeApp + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} run: | cat >> local.properties < release-notes.md echo "" >> release-notes.md - for dir in android-apk android-aab linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 windows-msi windows-exe macos-dmg; do + for dir in android-apk linux-deb linux-rpm linux-deb-arm64 linux-rpm-arm64 windows-exe macos-dmg; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -485,22 +517,29 @@ jobs: fi done + # The rolling nightly tag already exists, and the release action never moves an existing tag + - name: Move nightly tag to this commit + if: ${{ inputs.release_type == 'nightly' }} + run: | + git tag -f nightly "$GITHUB_SHA" + git push -f origin refs/tags/nightly + - name: Create Release uses: softprops/action-gh-release@v3 with: 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 draft: ${{ steps.vars.outputs.is_draft }} prerelease: ${{ steps.vars.outputs.is_prerelease }} files: | android-apk/*.apk - android-aab/*.aab linux-deb/*.deb linux-rpm/*.rpm - linux-appimage/*.AppImage linux-deb-arm64/*.deb linux-rpm-arm64/*.rpm - windows-msi/*.msi windows-exe/*.exe macos-dmg/*.dmg From 069b586d445632776a7455572aabbc8ccc4c5347 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 19 Oct 2026 11:50:53 +0600 Subject: [PATCH 080/132] chore: update release workflow and documentation for dependency management --- .github/workflows/spotube-release-binary.yml | 28 -------------------- AGENTS.md | 4 +-- README.md | 4 +-- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index db218921..4b76f5b5 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -34,8 +34,6 @@ concurrency: env: COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d - GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin - VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin jobs: prepare-deps: @@ -50,20 +48,6 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} path: compose-webview - - name: Checkout gradle-plugin - uses: actions/checkout@v7 - with: - repository: ${{ env.GRADLE_PLUGIN_REPO }} - token: ${{ secrets.GITHUB_TOKEN }} - path: gradle-plugin - - - name: Checkout vlcj-bundler - uses: actions/checkout@v7 - with: - repository: ${{ env.VLCJ_BUNDLER_REPO }} - token: ${{ secrets.GITHUB_TOKEN }} - path: vlcj-bundler - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -85,18 +69,6 @@ jobs: chmod +x gradlew ./gradlew publishToMavenLocal - - name: Publish gradle-plugin to mavenLocal - working-directory: gradle-plugin - run: | - chmod +x gradlew - ./gradlew publishToMavenLocal - - - name: Publish vlcj-bundler to mavenLocal - working-directory: vlcj-bundler - run: | - chmod +x gradlew - ./gradlew publishToMavenLocal - - name: Upload maven local uses: actions/upload-artifact@v7 with: diff --git a/AGENTS.md b/AGENTS.md index 6de06c88..e61c7d52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,9 +59,7 @@ ## Release workflow (`.github/workflows/release.yml`) - Triggered by `workflow_dispatch` with a `release_type` choice input (`stable` | `nightly`). -- `prepare-deps` job checks out & publishes to `mavenLocal`: - - `kdroidFilter/ComposeNativeWebview` (compose-webview) - - `team-spotube/gradle-plugin` (spotubeGradle + vlcjBundler plugins) +- `prepare-deps` job checks out & publishes `kdroidFilter/ComposeNativeWebview` (compose-webview) to `mavenLocal`. The spotube gradle plugin and vlcj-bundler now come from Maven Central. - Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. Desktop jobs ship GraalVM native installers (`packageGraalvm{Deb,Rpm,Exe,Dmg}`, output in `composeApp/build/compose/binaries/main/graalvm-/`), not jpackage/JVM ones. - **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release. - **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease. diff --git a/README.md b/README.md index 6ae62b54..10abcbce 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,8 @@ Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app i - **JDK 21** to run Gradle. The Gradle daemon JDK and the GraalVM toolchain are provisioned automatically. - **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev). - **Android SDK**, only for Android builds. -- **Unpublished dependencies.** Three dependencies aren't on Maven Central yet. Clone each one and run `./gradlew publishToMavenLocal` in it: +- **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it: - [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) - - [team-spotube/gradle-plugin](https://github.com/team-spotube/gradle-plugin) - - [team-spotube/vlcj-bundler-gradle-plugin](https://github.com/team-spotube/vlcj-bundler-gradle-plugin) - **Linux runtime:** `webkit2gtk-4.1` for the embedded webview. Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows). From 7ee3c70c96426fb0df046361e0fb143a32bb498a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 19 Oct 2026 18:50:27 +0600 Subject: [PATCH 081/132] fix(macos): add GraalVM runtime dependency and initialize GraalVm in main function --- composeApp/build.gradle.kts | 8 ++++++++ composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt | 4 ++++ gradle/libs.versions.toml | 1 + 3 files changed, 13 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1852a641..25f7cc44 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -302,6 +302,7 @@ kotlin { implementation(libs.jna) implementation(libs.nucleus.core.runtime) + implementation(libs.nucleus.graalvm.runtime) implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.decorated.window.tao) implementation(libs.compose.native.tray) @@ -401,6 +402,9 @@ nucleus.application { nativeDistributions { packageName = bundleId + // User-facing name (.app bundle, DMG productName, Launchpad/Dock); packageName above is the reverse-DNS id. + // The GraalVM DMG task reads only appName ?: packageName, not macOS.packageName + appName = if (isNightly) "Spotube Nightly" else "Spotube" packageVersion = version licenseFile = project.file("../LICENSE") @@ -435,6 +439,8 @@ nucleus.application { macOS { iconFile.set(project.file("$desktopIconDir/icon.icns")) + bundleID = if (isNightly) "$bundleId.nightly" else bundleId + dockName = appName notarization { } @@ -527,6 +533,8 @@ tasks.withType().matching { it.name == "copyGraalvmAwtSoLibs" }.configureE // Issue with compose plugin // https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks tasks.withType().configureEach { + // Populates the resources dir below with the VLC natives (appResourcesRootDir); hot run doesn't depend on it by default + dependsOn("prepareAppResources") jvmArgs( "--enable-native-access=ALL-UNNAMED" ) diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index e754c8d3..a4d02a13 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -38,6 +38,7 @@ import spotube.composeapp.generated.resources.spotube_logo import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.graalvm.GraalVmInitializer import dev.nucleusframework.window.tao.TaoScreenGeometry import io.github.vinceglb.filekit.FileKit import kotlinx.coroutines.CoroutineScope @@ -132,6 +133,9 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta @OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) fun main() { + // Native image only (no-op on the JVM): sets java.home, java.library.path and compose.application.resources.dir + // to the executable's dir, which the VLC loader needs to find the bundled libvlc + GraalVmInitializer.initialize() FileKit.init(appId = "org.spotube.spotube") initKoin() NewPipeDownloader.init(KoinPathsProvider.paths) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c441d82a..41f2c6a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -129,6 +129,7 @@ murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhas newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } +nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-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-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } From e92e62613e8ed43eb8b93a75713ea8002250ece6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 19 Oct 2026 19:15:13 +0600 Subject: [PATCH 082/132] chore: implement Linux AppImage bundling for GTK and WebKitGTK, update dependencies and documentation --- .github/workflows/spotube-release-binary.yml | 36 ++- README.md | 30 +- buildSrc/build.gradle.kts | 9 + .../gradle/DownloadLinuxGtkBundleTask.kt | 260 ++++++++++++++++++ composeApp/build.gradle.kts | 74 ++++- .../core/appimage/AppImageEnvironment.kt | 68 +++++ .../kotlin/org/spotube/spotube/main.kt | 7 + gradle/libs.versions.toml | 3 +- 8 files changed, 479 insertions(+), 8 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 4b76f5b5..fdb64445 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -166,7 +166,7 @@ jobs: - name: Install packaging tools run: | sudo apt-get update - sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf + sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd - name: Set up JDK 21 uses: actions/setup-java@v6 @@ -196,12 +196,20 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + # Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages + - name: Package Linux AppImage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + - name: Rename Linux artifacts run: | DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) cp "$DEB_FILE" Spotube-linux-x86_64.deb cp "$RPM_FILE" Spotube-linux-x86_64.rpm + APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1) + cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage - name: Upload DEB uses: actions/upload-artifact@v7 @@ -215,6 +223,12 @@ jobs: name: linux-rpm path: Spotube-linux-x86_64.rpm + - name: Upload AppImage + uses: actions/upload-artifact@v7 + with: + name: linux-appimage + path: Spotube-linux-x86_64.AppImage + - name: Setup tmate session on failure if: ${{ inputs.debug_ssh && failure() }} uses: mxschmitt/action-tmate@v3 @@ -245,7 +259,7 @@ jobs: - name: Install packaging tools run: | sudo apt-get update - sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf + sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd - name: Set up JDK 21 uses: actions/setup-java@v6 @@ -275,12 +289,20 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + # Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages + - name: Package Linux AppImage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + - name: Rename Linux artifacts run: | DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) cp "$DEB_FILE" Spotube-linux-aarch64.deb cp "$RPM_FILE" Spotube-linux-aarch64.rpm + APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1) + cp "$APPIMAGE_FILE" Spotube-linux-aarch64.AppImage - name: Upload DEB uses: actions/upload-artifact@v7 @@ -294,6 +316,12 @@ jobs: name: linux-rpm-arm64 path: Spotube-linux-aarch64.rpm + - name: Upload AppImage + uses: actions/upload-artifact@v7 + with: + name: linux-appimage-arm64 + path: Spotube-linux-aarch64.AppImage + - name: Setup tmate session on failure if: ${{ inputs.debug_ssh && failure() }} uses: mxschmitt/action-tmate@v3 @@ -479,7 +507,7 @@ jobs: run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md - for dir in android-apk linux-deb linux-rpm linux-deb-arm64 linux-rpm-arm64 windows-exe macos-dmg; do + for dir in android-apk linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 windows-exe macos-dmg; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -511,7 +539,9 @@ jobs: android-apk/*.apk linux-deb/*.deb linux-rpm/*.rpm + linux-appimage/*.AppImage linux-deb-arm64/*.deb linux-rpm-arm64/*.rpm + linux-appimage-arm64/*.AppImage windows-exe/*.exe macos-dmg/*.dmg diff --git a/README.md b/README.md index 10abcbce..86b305fc 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,18 @@ Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app i - **Android SDK**, only for Android builds. - **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it: - [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) -- **Linux runtime:** `webkit2gtk-4.1` for the embedded webview. +- **Linux runtime libraries** (needed to run the app, including `:composeApp:run`). The `.deb`/`.rpm` install these as dependencies from your distro. For a source build, install them yourself, **including VLC**. Only the `.AppImage` is self-contained (it bundles VLC, GTK 3 and WebKitGTK, and uses just the host's glibc, graphics, X11 and sound libraries): + + | Distro | Command | + |---|---| + | Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` | + | Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender`. `vlc-libs` is only in [RPM Fusion](https://rpmfusion.org/Configuration), so enable it first. | + | Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` | + | openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` (for wide codec support, switch to the [Packman](https://en.opensuse.org/Additional_package_repositories#Packman) repo's VLC) | + + Arch-based distros and openSUSE have no native package here. Use the `.AppImage`. The `.rpm` is built with Fedora package names, so it won't resolve on openSUSE. + + On Ubuntu 24.04+/Debian 13 `libgtk-3-0` and `libasound2` are named `libgtk-3-0t64` and `libasound2t64`; apt resolves this automatically. Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows). @@ -77,7 +88,7 @@ Development builds stay fast. The release-only optimizations (the Rust release p | OS | Requirements | |---|---| -| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages. Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build` | +| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages, `fuse` for `.AppImage`, and, for the AppImage's bundled libraries, `zstd` (plus `ar`/`tar` and `readelf`, from binutils). Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` (also install the [Linux runtime libraries](#prerequisites) to run what you build) | | macOS | Xcode Command Line Tools (`xcode-select --install`) | | Windows | Visual Studio Build Tools with the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. | @@ -104,12 +115,25 @@ Each OS can only build its own installers: | OS | Command | Output | |---|---|---| -| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` (also `packageGraalvmAppImage`) | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm}/` | +| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm`, then `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm,appimage}/` | | Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` | | macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` | Add `-Pnightly=true` for the nightly channel (separate icons and name). +The `.deb` and `.rpm` use your distro's shared libraries and declare them as dependencies, so the package manager installs them (on Fedora, `vlc-libs` needs RPM Fusion enabled). The `.AppImage` is the self-contained one: it bundles VLC, GTK 3 and WebKitGTK (see [What the AppImage bundles](#what-the-appimage-bundles)). + +#### What the AppImage bundles + +Build it with `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true`. Without `-PappImage=true` nothing is bundled and the app uses the system libraries, which is what the `.deb`/`.rpm` and `:composeApp:run` do. + +The bundle follows the [AppImage exclude list](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist) and what Tauri apps ship. It carries VLC (audio-only, from the [vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin) plugin's `bundleVlc` option), GTK 3, WebKitGTK 4.1, GLib/GIO, pango/cairo and their dependencies, plus glib-networking for HTTPS in the webview. It leaves the host's glibc, `libstdc++`, `libz`, GL/EGL/DRM, X11/xcb, ALSA, fontconfig/freetype/harfbuzz and the Wayland libraries alone, so the host's graphics, display and sound stack is used. Everything is taken from the Ubuntu 24.04 archive, so the AppImage needs glibc 2.38 or newer. + +How it works, in short: +- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) downloads the packages, keeps the libraries reachable from GTK/WebKit and relocates them with `patchelf`. Needs `ar`, `tar`, `zstd`, `readelf` and `patchelf`. +- WebKitGTK has its helper-process path compiled in, so the library is patched to look relative to the working directory, and at startup the app moves there ([`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt)). It also points `GIO_EXTRA_MODULES` at the bundled TLS module and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1`, because the bundled WebKitGTK 2.44's dmabuf renderer never paints (or aborts) against a newer host Mesa/compositor. The display backend is left alone: forcing X11 doesn't help, since the webview doesn't paint on XWayland either. +- Startup order matters: `main()` calls `GraalVmInitializer.initialize()` first, since the VLC loader and the environment setup read `compose.application.resources.dir`. + #### Reflection metadata (when a feature breaks only in the native build) A native image is a closed world: anything the app reaches through reflection, JNI or JNA (vlcj, the Zipline/QuickJS plugin engine, NewPipe, ktor, datastore, …) must be declared at build time. Nucleus generates most of this automatically. The rest is in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..28845360 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,9 @@ +// Build logic for the Linux AppImage bundle (see DownloadLinuxGtkBundleTask). Lives in buildSrc so the IDE +// resolves it; an `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio. +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt new file mode 100644 index 00000000..b9331fb5 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt @@ -0,0 +1,260 @@ +/* + * 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 . + */ + +// Bundles GTK 3 and WebKitGTK 4.1 into the Linux AppImage, the way Tauri apps do, so it runs on a +// system that has neither. Used only with -PappImage=true; deb/rpm depend on the distro's packages. +// +// The libraries come from the Ubuntu archive. Like the AppImage exclude list prescribes, nothing that +// has to match the host is bundled: glibc, libstdc++, libz, GL/EGL/DRM, X11/xcb, ALSA, fontconfig, +// freetype, harfbuzz, expat, gmp and the wayland libraries. What is left is pruned to the libraries +// reachable from GTK/WebKit through their ELF NEEDED entries. +// +// Layout (copied into the AppDir root by Nucleus; AppRun puts $APPDIR/usr/lib on LD_LIBRARY_PATH): +// usr/lib/*.so.N shared libraries, RUNPATH=$ORIGIN +// usr/lib//webkit2gtk-4.1/WebKit*Process helper processes +// usr/lib//gio/modules/libgiognutls.so TLS backend for WebKit/libsoup +// libwebkit2gtk has the helper path compiled in as /usr/lib//webkit2gtk-4.1; "/usr" is +// patched to "././" (same length), so the helpers resolve relative to the working directory, which the +// app sets to $APPDIR/usr at startup (AppImageEnvironment). Needs `ar`, `tar` (+ `zstd`), `readelf` +// and `patchelf` on the build machine. + +package org.spotube.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import java.io.File +import java.net.URI +import java.util.zip.GZIPInputStream + +@DisableCachingByDefault(because = "Downloads from the network") +abstract class DownloadLinuxGtkBundleTask : DefaultTask() { + /** Root passed on to Nucleus' `appResourcesRootDir`; the files land in `/linux-/usr`. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + /** Ubuntu release the binaries come from. Its glibc is the minimum the bundle runs on. */ + @get:Input + abstract val ubuntuRelease: Property + + init { + group = "dependencies" + description = "Downloads and relocates GTK 3 and WebKitGTK 4.1 for the Linux AppImage." + } + + private val packages = listOf( + "libgtk-3-0t64", "libwebkit2gtk-4.1-0", "libjavascriptcoregtk-4.1-0", "glib-networking", + "libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatomic1", "libatspi2.0-0t64", "libbrotli1", + "libbz2-1.0", "libcairo-gobject2", "libcairo2", "libcap2", "libdatrie1", "libdbus-1-3", + "libdw1t64", "libelf1t64", "libenchant-2-2", "libepoxy0", "libevdev2", "libffi8", "libgcrypt20", + "libgdk-pixbuf-2.0-0", "libglib2.0-0t64", "libgnutls30t64", "libgssapi-krb5-2", + "libgstreamer-gl1.0-0", "libgstreamer-plugins-base1.0-0", "libgstreamer1.0-0", "libgudev-1.0-0", + "libharfbuzz-icu0", "libhogweed6t64", "libhyphen0", "libicu74", "libidn2-0", "libjpeg-turbo8", + "libjpeg8", "libk5crypto3", "libkeyutils1", "libkrb5-3", "libkrb5support0", "liblcms2-2", + "liblz4-1", "liblzma5", "libmanette-0.2-0", "libnettle8t64", "libnghttp2-14", "liborc-0.4-0t64", + "libp11-kit0", "libpango-1.0-0", "libpangocairo-1.0-0", "libpangoft2-1.0-0", "libpixman-1-0", + "libpng16-16t64", "libpsl5t64", "libseccomp2", "libsecret-1-0", "libsharpyuv0", "libsoup-3.0-0", + "libsqlite3-0", "libsystemd0", "libtasn1-6", "libthai0", "libudev1", "libunistring5", "libunwind8", + "libwebp7", "libwebpdemux2", "libwebpmux3", "libxcb-render0", "libxcb-shm0", "libxcomposite1", + "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxinerama1", "libxkbcommon0", + "libxml2", "libxrandr2", "libxrender1", "libxslt1.1", "libzstd1", + ) + + // Provided by the host, per the AppImage exclude list (which includes the wayland libraries). Never + // copied even if a bundled package carries them. + private val hostLibraries = setOf( + "libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", "libresolv.so.2", + "libutil.so.1", "libnsl.so.1", "libstdc++.so.6", "libgcc_s.so.1", "libz.so.1", "libGL.so.1", + "libEGL.so.1", "libGLX.so.0", "libGLdispatch.so.0", "libOpenGL.so.0", "libdrm.so.2", + "libgbm.so.1", "libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libasound.so.2", + "libfontconfig.so.1", "libfreetype.so.6", "libharfbuzz.so.0", "libexpat.so.1", "libgmp.so.10", + "libwayland-client.so.0", "libwayland-server.so.0", "libwayland-cursor.so.0", + "libwayland-egl.so.1", "libuuid.so.1", "libgpg-error.so.0", "libfribidi.so.0", "libXau.so.6", + "libXdmcp.so.6", "libpcre2-8.so.0", "libselinux.so.1", "libmount.so.1", "libblkid.so.1", + ) + + private val rootLibraries = listOf( + "libgtk-3.so.0", "libgdk-3.so.0", "libwebkit2gtk-4.1.so.0", "libjavascriptcoregtk-4.1.so.0", + ) + + @TaskAction + fun run() { + val arch = System.getProperty("os.arch").lowercase() + val isArm = arch.contains("aarch64") || arch.contains("arm64") + val resourceDir = if (isArm) "linux-arm64" else "linux-x64" + val debArch = if (isArm) "arm64" else "amd64" + val triplet = if (isArm) "aarch64-linux-gnu" else "x86_64-linux-gnu" + val mirror = if (isArm) "http://ports.ubuntu.com/ubuntu-ports" else "http://archive.ubuntu.com/ubuntu" + + val target = outputDir.dir(resourceDir).get().asFile + val marker = File(target, ".complete") + if (marker.exists()) { + println("Linux GTK/WebKit bundle already present. Skipping download.") + return + } + for (tool in listOf("ar", "tar", "readelf", "patchelf")) { + val found = try { + ProcessBuilder(tool, "--version").redirectErrorStream(true).start() + .also { it.inputStream.readBytes() }.waitFor() == 0 + } catch (_: Exception) { + false + } + if (!found) throw GradleException("`$tool` is required to bundle GTK/WebKit on Linux but was not found on PATH.") + } + + target.deleteRecursively() + val work = File(temporaryDir, "work").apply { deleteRecursively(); mkdirs() } + val root = File(work, "root").apply { mkdirs() } + + val index = fetchPackageIndex(mirror, debArch) + for (pkg in packages) { + val filename = index[pkg] ?: throw GradleException("Package $pkg not found in the ${ubuntuRelease.get()} index") + val deb = File(work, "$pkg.deb") + println("Downloading $pkg") + URI("$mirror/$filename").toURL().openStream().use { input -> deb.outputStream().use { input.copyTo(it) } } + extractDeb(deb, root, File(work, "ar-$pkg")) + } + + val srcLib = File(root, "usr/lib/$triplet") + val libOut = File(target, "usr/lib").apply { mkdirs() } + + // Libraries by SONAME (libbz2.so.1.0 and friends don't end in a single number) + val available = HashMap() + srcLib.listFiles { f -> f.isFile && ".so" in f.name }.orEmpty().forEach { file -> + soname(file)?.let { available.putIfAbsent(it, file) } + } + + val helperSrc = File(srcLib, "webkit2gtk-4.1") + val helperOut = File(libOut, "$triplet/webkit2gtk-4.1") + val helpers = listOf("WebKitWebProcess", "WebKitNetworkProcess", "WebKitGPUProcess") + for (name in helpers) { + // copyTo drops the mode bits; WebKit spawns these, so they must stay executable + File(helperSrc, name).copyTo(File(helperOut, name), overwrite = true).setExecutable(true, false) + } + val injected = File(helperSrc, "injected-bundle/libwebkit2gtkinjectedbundle.so") + injected.copyTo(File(helperOut, "injected-bundle/${injected.name}"), overwrite = true) + val gioTls = File(srcLib, "gio/modules/libgiognutls.so") + gioTls.copyTo(File(libOut, "$triplet/gio/modules/${gioTls.name}"), overwrite = true) + + // Keep only what GTK, WebKit, their helpers and the TLS module pull in via NEEDED + val pending = ArrayDeque(rootLibraries) + listOf(*helpers.map { File(helperOut, it) }.toTypedArray(), File(helperOut, "injected-bundle/${injected.name}"), + File(libOut, "$triplet/gio/modules/${gioTls.name}")).forEach { pending += needed(it) } + val keep = LinkedHashSet() + while (pending.isNotEmpty()) { + val name = pending.removeFirst() + if (name in hostLibraries || name in keep) continue + val file = available[name] ?: continue + keep += name + pending += needed(file) + } + for (name in keep) available.getValue(name).copyTo(File(libOut, name), overwrite = true) + + patchWebKitPaths(File(libOut, "libwebkit2gtk-4.1.so.0"), triplet) + + // Libraries find each other next to themselves; the helpers and modules sit two/three levels down + libOut.listFiles { f -> f.isFile }.orEmpty().forEach { patchRpath(it, "\$ORIGIN") } + helpers.forEach { patchRpath(File(helperOut, it), "\$ORIGIN/../..") } + patchRpath(File(helperOut, "injected-bundle/${injected.name}"), "\$ORIGIN/../../..") + patchRpath(File(libOut, "$triplet/gio/modules/${gioTls.name}"), "\$ORIGIN/../../..") + + marker.writeText("") + work.deleteRecursively() + println("GTK/WebKit bundled into $target (${keep.size} libraries)") + } + + /** "/usr/lib//webkit2gtk-4.1[/injected-bundle]" -> "././lib//webkit2gtk-4.1[/...]". */ + private fun patchWebKitPaths(lib: File, triplet: String) { + val bytes = lib.readBytes() + val needle = "/usr/lib/$triplet/webkit2gtk-4.1".toByteArray() + val replacement = "././".toByteArray() + var count = 0 + var i = 0 + while (i <= bytes.size - needle.size) { + var match = true + for (j in needle.indices) { + if (bytes[i + j] != needle[j]) { match = false; break } + } + if (match) { + replacement.copyInto(bytes, i) + count++ + i += needle.size + } else { + i++ + } + } + if (count == 0) throw GradleException("WebKit helper path not found in ${lib.name}; the bundling layout needs updating") + lib.writeBytes(bytes) + } + + private fun soname(file: File): String? = + exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence() + .firstOrNull { "(SONAME)" in it }?.substringAfter('[')?.substringBefore(']') + + private fun needed(file: File): List = + exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence() + .filter { "(NEEDED)" in it }.map { it.substringAfter('[').substringBefore(']') }.toList() + + /** name -> pool filename; `-updates` is read first so security/bugfix builds win. */ + private fun fetchPackageIndex(mirror: String, debArch: String): Map { + val release = ubuntuRelease.get() + val index = HashMap() + for (suite in listOf("$release-updates", release)) { + for (component in listOf("main", "universe")) { + val url = URI("$mirror/dists/$suite/$component/binary-$debArch/Packages.gz").toURL() + GZIPInputStream(url.openStream()).bufferedReader().useLines { lines -> + var name: String? = null + for (line in lines) { + when { + line.startsWith("Package: ") -> name = line.removePrefix("Package: ") + line.startsWith("Filename: ") -> name?.let { index.putIfAbsent(it, line.removePrefix("Filename: ")) } + line.isEmpty() -> name = null + } + } + } + } + } + return index + } + + private fun extractDeb(deb: File, into: File, scratch: File) { + scratch.mkdirs() + exec(scratch, "ar", "x", deb.absolutePath) + val data = scratch.listFiles { f -> f.name.startsWith("data.tar") }?.singleOrNull() + ?: throw GradleException("No data archive in ${deb.name}") + // GNU tar picks the decompressor from the content (zstd needs the zstd binary installed) + exec(scratch, "tar", "-xf", data.absolutePath, "-C", into.absolutePath) + } + + private fun patchRpath(file: File, rpath: String) { + exec(file.parentFile, "patchelf", "--set-rpath", rpath, file.absolutePath) + } + + private fun exec(dir: File, vararg command: String): String { + val process = ProcessBuilder(*command).directory(dir).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText() + if (process.waitFor() != 0) { + throw GradleException("`${command.joinToString(" ")}` failed:\n$output") + } + return output + } +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1852a641..44892138 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -23,6 +23,7 @@ import dev.nucleusframework.desktop.application.dsl.TargetFormat import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.gradle.api.tasks.Copy +import org.spotube.gradle.DownloadLinuxGtkBundleTask import java.io.FileInputStream import java.util.Properties @@ -106,9 +107,30 @@ tasks.configureEach { } } +// Windows/macOS always bundle VLC. On Linux only the self-contained AppImage (-PappImage=true) bundles +// VLC, GTK and WebKitGTK; deb/rpm use the distro's packages. +val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux") +val bundleForAppImage = isLinuxHost && providers.gradleProperty("appImage").map { it.toBoolean() }.getOrElse(false) + vlcjBundler { packageName = "$bundleId.core.generated" // choose a different package objectName = "VLCBundleLoaderGenerated" // or rename the object + bundleVlc = !isLinuxHost || bundleForAppImage +} + +// GTK 3 + WebKitGTK for the AppImage (see DownloadLinuxGtkBundleTask in buildSrc) +val downloadLinuxGtkBundle = tasks.register("downloadLinuxGtkBundle") { + outputDir.set(layout.buildDirectory.dir("linux-gtk-bundle")) + ubuntuRelease.set("noble") +} +val linuxGtkBundleDir: Provider = downloadLinuxGtkBundle.flatMap { it.outputDir } + +// One resources root holding VLC and GTK/WebKit, laid out for the AppImage +val assembleAppImageResources = tasks.register("assembleAppImageResources") { + from(vlcjBundler.vlcNativesDirectory) + from(linuxGtkBundleDir) + exclude("**/.complete") + into(layout.buildDirectory.dir("appimage-resources")) } @@ -302,6 +324,7 @@ kotlin { implementation(libs.jna) implementation(libs.nucleus.core.runtime) + implementation(libs.nucleus.graalvm.runtime) implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.decorated.window.tao) implementation(libs.compose.native.tray) @@ -404,7 +427,9 @@ nucleus.application { packageVersion = version licenseFile = project.file("../LICENSE") - appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) + appResourcesRootDir.set( + if (bundleForAppImage) assembleAppImageResources.map { layout.buildDirectory.dir("appimage-resources").get() } else vlcjBundler.vlcNativesDirectory + ) targetFormats( TargetFormat.Dmg, @@ -424,6 +449,44 @@ nucleus.application { modules("jdk.security.auth") debMaintainer = "Team Spotube " rpmLicenseType = "AGPL-3.0-or-later" + // deb/rpm use the distro's shared libraries (only the AppImage bundles VLC). VLC is not + // bundled on Linux otherwise, the webview needs WebKitGTK 4.1, and the window/tray/MPRIS + // natives link GTK 3, GLib/GIO and D-Bus. Setting these replaces electron-builder's + // Electron-specific defaults (libnss3, libnotify, ...). + debDepends = listOf( + "libvlc5", + "vlc-plugin-base", + "libwebkit2gtk-4.1-0", + "libgtk-3-0 | libgtk-3-0t64", + "libglib2.0-0 | libglib2.0-0t64", + "libdbus-1-3", + "libasound2 | libasound2t64", + "libgl1", + "libfontconfig1", + "libfreetype6", + "libx11-6", + "libxext6", + "libxi6", + "libxrender1", + "libxtst6", + ) + // vlc-libs comes from RPM Fusion (Fedora ships no VLC); the README tells users to enable it + rpmRequires = listOf( + "vlc-libs", + "webkit2gtk4.1", + "gtk3", + "glib2", + "dbus-libs", + "alsa-lib", + "mesa-libGL", + "fontconfig", + "freetype", + "libX11", + "libXext", + "libXi", + "libXrender", + "libXtst", + ) } windows { iconFile.set(project.file("$desktopIconDir/icon.ico")) @@ -560,3 +623,12 @@ run { .configureEach { enabled = false } } } + +// Nucleus' resource copies (prepareAppResources, copyGraalvmAppResources) drop the execute bit, but WebKitGTK +// spawns its helper processes from the AppImage bundle (DownloadLinuxGtkBundleTask in buildSrc) and fails with +// "Permission denied" otherwise. +tasks.matching { it.name == "prepareAppResources" || it.name == "copyGraalvmAppResources" }.configureEach { + (this as? org.gradle.api.tasks.AbstractCopyTask)?.filesMatching("**/webkit2gtk-4.1/WebKit*Process") { + permissions { unix("rwxr-xr-x") } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt new file mode 100644 index 00000000..b2f77f13 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt @@ -0,0 +1,68 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.appimage + +import com.sun.jna.Function +import com.sun.jna.NativeLibrary +import java.io.File + +/** + * Makes the GTK 3 and WebKitGTK bundled into the AppImage usable. The AppImage's AppRun already puts + * `$APPDIR/usr/lib` on `LD_LIBRARY_PATH`; the rest has to be done in-process, before any GTK/WebKit code + * loads, because the AppRun is generated and can't be customised: + * + * - WebKitGTK has its helper-process path compiled in. The build patches it to a path relative to the + * working directory (see `DownloadLinuxGtkBundleTask` in buildSrc), so the working directory is moved to + * `$APPDIR/usr`. + * - TLS in WebKit/libsoup needs glib-networking's GIO module, which lives in the bundle. + * - The bundled WebKitGTK 2.44 uses its dmabuf renderer by default. Against a newer host Mesa/compositor + * that path never paints the first frame (the webview stays see-through or black until the window is + * resized), and the bundled GTK/epoxy can even abort while creating the GL context. Disabling it makes + * the webview paint on native Wayland. Left alone if the user already set it. + * + * Does nothing outside the AppImage (deb/rpm/dev runs have no bundled `usr/lib/libgtk-3.so.0`). + */ +object AppImageEnvironment { + fun apply() { + if (!System.getProperty("os.name").lowercase().contains("linux")) return + val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return + val usrDir = File(resourcesDir, "usr") + if (!File(usrDir, "lib/libgtk-3.so.0").isFile) return + + try { + val libc = NativeLibrary.getInstance("c") + val setenv = libc.getFunction("setenv") + val triplet = if (System.getProperty("os.arch").lowercase().let { "aarch64" in it || "arm64" in it }) { + "aarch64-linux-gnu" + } else { + "x86_64-linux-gnu" + } + setenv.set("GIO_EXTRA_MODULES", File(usrDir, "lib/$triplet/gio/modules").absolutePath) + if (System.getenv("WEBKIT_DISABLE_DMABUF_RENDERER").isNullOrBlank()) { + setenv.set("WEBKIT_DISABLE_DMABUF_RENDERER", "1") + } + libc.getFunction("chdir").invokeInt(arrayOf(usrDir.absolutePath)) + } catch (_: Throwable) { + // Falls back to the host's libraries, which only works where they are installed + } + } + + private fun Function.set(name: String, value: String) { + invokeInt(arrayOf(name, value, 1)) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index e754c8d3..a8d10f0e 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -38,6 +38,7 @@ import spotube.composeapp.generated.resources.spotube_logo import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.graalvm.GraalVmInitializer import dev.nucleusframework.window.tao.TaoScreenGeometry import io.github.vinceglb.filekit.FileKit import kotlinx.coroutines.CoroutineScope @@ -50,6 +51,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent import org.koin.core.component.get +import org.spotube.spotube.core.appimage.AppImageEnvironment import org.spotube.spotube.core.di.initKoin import org.spotube.spotube.core.i18n.AppLocaleProvider import org.spotube.spotube.core.newpipe.NewPipeDownloader @@ -132,6 +134,11 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta @OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) fun main() { + // Sets compose.application.resources.dir in the native image. initKoin() creates the audio + // player, whose VLC loader reads it, so it can't wait for nucleusApplication() (which calls + // this again, harmlessly). Without it the bundled VLC is skipped for the system one. + GraalVmInitializer.initialize() + AppImageEnvironment.apply() FileKit.init(appId = "org.spotube.spotube") initKoin() NewPipeDownloader.init(KoinPathsProvider.paths) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c441d82a..d3087d75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,7 +72,7 @@ ksp = "2.3.5" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" -vlcjBundler = "0.1.0" +vlcjBundler = "0.2.0" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" @@ -131,6 +131,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-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" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } From 64a78727ced34ffcb9ba778261b281daa40167cd Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 19 Oct 2026 19:18:25 +0600 Subject: [PATCH 083/132] Update gradle/libs.versions.toml --- gradle/libs.versions.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 648ecb22..d3087d75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -129,7 +129,6 @@ murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhas newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } -nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-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-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" } From 38e9bbc1d865477fcbaf8d5770d8a0cdc3e77831 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 13:00:29 +0600 Subject: [PATCH 084/132] fix(android): create intermediate directories for Rust targets to prevent runtime errors --- composeApp/build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index edb6c6f7..ef0ab92f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -18,6 +18,7 @@ import gobley.gradle.GobleyHost import gobley.gradle.Variant import gobley.gradle.cargo.dsl.jvm +import gobley.gradle.rust.targets.RustAndroidTarget import dev.nucleusframework.desktop.application.dsl.NativeImageOptimization import dev.nucleusframework.desktop.application.dsl.TargetFormat import org.jetbrains.compose.reload.gradle.ComposeHotRun @@ -614,6 +615,15 @@ cargo { } } +// Gobley adds build/intermediates/rust// as a jniLibs source dir, but AGP leaves source dirs +// that don't exist at configuration time out of the merge inputs. On a clean build (CI) libcompose_app.so would +// then silently miss the APK and crash at runtime with UnsatisfiedLinkError, so create the dirs up front. +RustAndroidTarget.entries.forEach { target -> + Variant.entries.forEach { variant -> + layout.buildDirectory.dir("intermediates/rust/${target.rustTriple}/$variant").get().asFile.mkdirs() + } +} + // Google only ships x86_64 Linux NDK toolchains, so on a Linux arm64 host the Android Rust targets can never // link (clang: "Exec format error"). Desktop packaging doesn't need them; Android is built on x86_64 runners. run { From 9d5854023bf41ee89db59937aea1a6722c56ed02 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 13:19:04 +0600 Subject: [PATCH 085/132] fix(android): update sharing functionality to use chooser intent with new task flag --- .../org/spotube/spotube/core/share/AndroidShareService.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt index 91a7a02e..f75039e5 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt @@ -18,15 +18,19 @@ package org.spotube.spotube.core.share import android.content.Context +import android.content.Intent import androidx.core.app.ShareCompat import org.spotube.spotube.core.share.ShareService class AndroidShareService(private val context: Context) : ShareService { override fun share(url: String, title: String) { - ShareCompat.IntentBuilder(context) + // context is the application context (Koin), which can only start activities in a new task + val chooser = ShareCompat.IntentBuilder(context) .setType("text/plain") .setSubject(title) .setText(url) - .startChooser() + .createChooserIntent() + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) } } From 0b21eff4c7b431f61f958a0da26411701a735b42 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 13:44:30 +0600 Subject: [PATCH 086/132] fix(android): simplify service start logic for PlaybackService to avoid crashes on background playback --- .../kotlin/org/spotube/spotube/MainActivity.kt | 10 ++-------- .../core/audioplayer/AudioPlayer.android.kt | 18 +++++++----------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt index aed7b61e..e8f0437b 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt @@ -18,7 +18,6 @@ package org.spotube.spotube import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -37,13 +36,8 @@ class MainActivity : ComponentActivity() { FileKit.init(this) NewPipeDownloader.init(Paths()) - // Start PlaybackService from Activity context (allowed on Android 12+) - val serviceIntent = Intent(this, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } + // Plain start from the foreground; Media3 promotes PlaybackService itself once playback starts + startService(Intent(this, PlaybackService::class.java)) intent?.dataString?.let(ExternalUriHandler::onNewUri) setContent { diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index df055370..2bda1426 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -19,7 +19,6 @@ package org.spotube.spotube.core.audioplayer import android.content.Context import android.content.Intent -import android.os.Build import android.util.Log import androidx.media3.common.AudioAttributes import androidx.media3.common.C @@ -52,17 +51,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val appContext: Context = (context as Context).applicationContext private fun ensureServiceStarted() { - // On Android 12+ starting a foreground service from the background throws - // (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or - // remote control applies playback while the app is backgrounded. Never let - // that crash the app; playback itself runs in-process without the service. + // A plain start, never startForegroundService: that obliges the service to call + // startForeground() within seconds of *every* call, and once Media3 has demoted it + // on pause nothing does, so the app is killed (ForegroundServiceDidNotStartInTime). + // Media3's MediaSessionService promotes itself to the foreground when playback starts. + // Starting from the background (jam sync, remote control) throws; playback still runs + // in-process without the service, so never let that crash the app. try { - val intent = Intent(appContext, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - appContext.startForegroundService(intent) - } else { - appContext.startService(intent) - } + appContext.startService(Intent(appContext, PlaybackService::class.java)) } catch (e: Exception) { Log.w("AudioPlayer", "Failed to start playback service", e) } From facc8ed0b941609c2dfd42d0146cb40e576976f2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 14:28:18 +0600 Subject: [PATCH 087/132] fix(android): update playback state management to ensure guests follow host's play state --- composeApp/build.gradle.kts | 8 ++ .../spotube/spotube/core/jam/JamProtocol.kt | 8 +- .../spotube/core/jam/JamRoomService.kt | 75 ++++++++++++------- .../modules/shell/AppExpandedPlayer.kt | 3 + .../modules/shell/player_queue/QueueSheet.kt | 16 ++-- gradle.properties | 5 +- 6 files changed, 81 insertions(+), 34 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ef0ab92f..b717523d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -389,6 +389,14 @@ android { isMinifyEnabled = false signingConfig = signingConfigs.getByName("release") } + getByName("debug") { + // Debug and release share the application id, so sign both with the release key + // when it's configured; otherwise one can't be installed over the other. + // Without local.properties signing (contributors) keep the default debug keystore. + if (signingConfigs.getByName("release").storeFile != null) { + signingConfig = signingConfigs.getByName("release") + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt index 8e268e38..03231e66 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt @@ -45,7 +45,7 @@ sealed class JamMessage { * merely auto-advanced because its song ended (guests stay put). */ val follow: Boolean = false, - /** Host's live play state — late-joining guests start with it. */ + /** Room-wide play/pause; guests follow every change of it. */ val isPlaying: Boolean = false) : JamMessage() @Serializable @@ -76,11 +76,15 @@ sealed class JamMessage { } /** - * Playback commands. Only queue navigation is global — play/pause, seek, + * Playback commands. Queue navigation and play/pause are global — seek, * volume, shuffle and loop are local to each participant. */ @Serializable sealed class PlaybackCmd { + @Serializable + @SerialName("setPlaying") + data class SetPlaying(val playing: Boolean) : PlaybackCmd() + @Serializable @SerialName("skipNext") data object SkipNext : PlaybackCmd() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt index f9963e90..f317e6ec 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt @@ -33,11 +33,14 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.random.Random import org.jetbrains.compose.resources.getString @@ -52,9 +55,9 @@ import kotlin.time.Clock * Sync rules: * - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo] * from anyone are applied by the host, then broadcast via the retained state topic. - * - Play/pause, seek, volume and loop are local to each device — never broadcast. - * When the queue moves on, a paused participant stays paused; a playing one - * keeps playing the new current item. + * - Play/pause is global: anyone's play/pause is sent to the host, which applies it + * and broadcasts it; guests follow every change of the host's play state. + * - Seek, volume and loop are local to each device — never broadcast. * - Shuffle is host-only; guests mirror the host's shuffle setting. * - Guests can only add to the queue (suggest); the host applies suggestions. * - If the host leaves, the participant with the lowest client id takes over. @@ -63,7 +66,7 @@ private data class PlaybackBroadcast( val queue: List, val current: QueueEntry?, val shuffle: Boolean, - val playerState: PlayerState) + val isPlaying: Boolean) class JamRoomService( private val jamClient: JamRoomClient, @@ -73,6 +76,20 @@ class JamRoomService( private val log = Logger.withTag("JamRoomService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + /** + * Play/pause intent. Transient states (buffering between tracks, loading, completion) + * keep the last value, so they never pause or resume the room. + */ + private val playIntent: StateFlow = audioPlayer.playerStateFlow + .mapNotNull { state -> + when (state) { + PlayerState.PLAYING -> true + PlayerState.PAUSED -> false + else -> null + } + } + .stateIn(scope, SharingStarted.Eagerly, audioPlayer.playerStateFlow.value == PlayerState.PLAYING) + private val _role = MutableStateFlow(null) val role: StateFlow = _role.asStateFlow() @@ -116,8 +133,8 @@ class JamRoomService( */ private var autoAdvanceDeadlineMs = 0L - /** Guest side: playback has started at least once (local control is the guest's own). */ - private var hasStartedPlayback = false + /** Guest side: the room's play state from the last host broadcast (null until the first one). */ + private var lastHostIsPlaying: Boolean? = null /** Host side: client ids banned for this session. */ private val bannedClientIds = mutableSetOf() @@ -145,12 +162,14 @@ class JamRoomService( audioPlayer.completionFlow .onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS } .launchIn(scope) - // Once a guest has played on its own (or was started by the host), its - // play/pause is its own — the host's play state only starts fresh joiners. - audioPlayer.playerStateFlow - .onEach { state -> - if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) { - hasStartedPlayback = true + // A guest's own play/pause (UI, notification, headset) goes to the host, which + // applies it for the whole room. Intent that matches the room's state came from + // following the host, so it isn't echoed back. + playIntent + .onEach { playing -> + val roomPlaying = lastHostIsPlaying + if (_role.value == JamRole.Guest && !leaving && roomPlaying != null && playing != roomPlaying) { + publishCommand(PlaybackCmd.SetPlaying(playing)) } } .launchIn(scope) @@ -182,7 +201,7 @@ class JamRoomService( bannedClientIds.clear() leaving = false autoAdvanceDeadlineMs = 0L - hasStartedPlayback = false + lastHostIsPlaying = null startHostBroadcast() persistLastCode(code) code @@ -214,7 +233,7 @@ class JamRoomService( lastAppliedItems = emptyList() lastAppliedIndex = -1 leaving = false - hasStartedPlayback = false + lastHostIsPlaying = null persistLastCode(normalized) } } @@ -232,7 +251,7 @@ class JamRoomService( lastAppliedItems = emptyList() lastAppliedIndex = -1 bannedClientIds.clear() - hasStartedPlayback = false + lastHostIsPlaying = null } // ---------- Controls (called from the UI) ---------- @@ -294,8 +313,8 @@ class JamRoomService( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, audioPlayer.shuffleModeFlow, - audioPlayer.playerStateFlow) { queue, current, shuffle, playerState -> - PlaybackBroadcast(queue, current, shuffle, playerState) + playIntent) { queue, current, shuffle, isPlaying -> + PlaybackBroadcast(queue, current, shuffle, isPlaying) } .onEach { broadcast -> if (_role.value != JamRole.Host) return@onEach @@ -311,7 +330,7 @@ class JamRoomService( currentIndex = index.coerceAtLeast(0), shuffleEnabled = broadcast.shuffle, follow = now() > autoAdvanceDeadlineMs, - isPlaying = broadcast.playerState == PlayerState.PLAYING) + isPlaying = broadcast.isPlaying) ) } .launchIn(this) @@ -332,15 +351,17 @@ class JamRoomService( _shuffleEnabled.value = state.shuffleEnabled runCatching { audioPlayer.shuffle(state.shuffleEnabled) } - // Late joiner: the host is already playing, so start immediately. Once - // this guest has played on its own, the host's play state is ignored. - if (state.isPlaying && !hasStartedPlayback) { - runCatching { audioPlayer.play() } - .onFailure { log.w(it) { "Failed to start playback on host play state" } } + // Play/pause is room-wide: follow every change of the host's play state. The room + // state is recorded first, so the resulting local intent isn't sent back as a command. + if (state.isPlaying != lastHostIsPlaying) { + lastHostIsPlaying = state.isPlaying + if (state.isPlaying != playIntent.value) { + runCatching { if (state.isPlaying) audioPlayer.play() else audioPlayer.pause() } + .onFailure { log.w(it) { "Failed to follow the host's play state" } } + } } val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } - val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING if (items != lastAppliedItems) { val previous = lastAppliedItems @@ -369,7 +390,7 @@ class JamRoomService( runCatching { audioPlayerQueue.load( entries = items.map { it.toQueueEntry() }, - autoPlay = wasPlaying, + autoPlay = state.isPlaying, startPosition = startIndex) }.onFailure { log.w(it) { "Failed to apply jam queue" } } return @@ -419,6 +440,10 @@ class JamRoomService( private suspend fun applyCommand(command: PlaybackCmd) { when (command) { + // Applied locally; the host broadcast carries the new play state to every guest + is PlaybackCmd.SetPlaying -> runCatching { + if (command.playing) audioPlayer.play() else audioPlayer.pause() + } PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() } PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() } is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt index a95bb526..2270c98e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt @@ -431,6 +431,7 @@ fun AppExpandedPlayer( val albumId = currentTrack?.album?.id if (albumId != null) { navigationCommands.navigateTo(Routes.Album(albumId)) + onCollapse() } onGoToAlbum() }) @@ -535,6 +536,8 @@ fun AppExpandedPlayer( artists = playerUiState.artistList, onArtistClick = { artistId -> navigationCommands.navigateTo(Routes.Artist(artistId)) + // The page opens beneath the player, so get out of the way + onCollapse() }, style = AppTheme.typography.bodyLarge, color = AppTheme.colors.onSurfaceVariant, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt index 4f9d09c1..a15efb6a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt @@ -17,7 +17,10 @@ package org.spotube.spotube.modules.shell.player_queue +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -34,9 +37,6 @@ import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.rememberMillenniumDrawerState private val SlidingSheetBreakpoint = 840.dp -private val MaxHeightSheetDetent = SheetDetent("max-height-85-percent") { containerHeight, sheetHeight -> - minOf(sheetHeight, containerHeight * 0.85f) -} @Composable fun QueueSheet( @@ -88,9 +88,11 @@ internal fun BottomSheetContent( content: @Composable () -> Unit ) { if (!isVisible) return + // Show the whole sheet and cap the content instead: a detent that reveals only part of a + // full-height sheet leaves the bottom of the queue list off-screen and unreachable. val sheetState = rememberModalBottomSheetState( - initialDetent = MaxHeightSheetDetent, - detents = listOf(SheetDetent.Hidden, MaxHeightSheetDetent) + initialDetent = SheetDetent.FullyExpanded, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded) ) LaunchedEffect(sheetState.bottomSheetState.currentDetent) { if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { @@ -102,6 +104,8 @@ internal fun BottomSheetContent( contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), contentSpacing = 8.dp ) { - content() + Box(Modifier.fillMaxWidth().fillMaxHeight(0.85f)) { + content() + } } } diff --git a/gradle.properties b/gradle.properties index a49b0977..dc5eecc2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,4 +34,7 @@ android.builtInKotlin=false android.newDsl=false android.sourceset.disallowProvider=false -org.gradle.vfs.watch=true \ No newline at end of file +org.gradle.vfs.watch=true + +nightly=true +buildNumber=999 \ No newline at end of file From b38a4f6963b41436b7961953f990f209878a59cb Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 15:11:10 +0600 Subject: [PATCH 088/132] feat(remote): redesign remote control and devices screens, fix connection issues - Remove the unused "Enable Connect" setting and its strings - Redesign RemoteControlScreen to match the expanded player: artwork sized to fit, side-by-side queue panel on wide screens, proper connecting / disconnected / error states, "Playing on " subtitle - Seek and volume sliders keep local drag state and only commit on release (volume throttled while dragging) instead of sending a command per frame - Redesign DevicesScreen with the settings card components, a connected device card, error banner and empty state; ViewModels expose one UiState - Fix tapping an already-connected device doing nothing; reopen its remote screen instead, and switch devices by disconnecting first - Fix the controller getting stuck in Connecting after the host denies a request: Ktor never delivers Close frames to `incoming`, so detect the close reason after the receive loop ends - Fix a UI freeze when leaving the devices screen on desktop: run discovery (and JmDNS's blocking close/resolve) on Dispatchers.IO - Advertise the LAN address of the default-route interface on desktop; dns-sd-kt otherwise picks any site-local address, e.g. a Docker bridge --- .../discovery/AdvertiseAddress.android.kt | 21 + .../composeResources/values-ar/strings.xml | 10 - .../composeResources/values-bn/strings.xml | 10 - .../composeResources/values-de/strings.xml | 10 - .../composeResources/values-es/strings.xml | 10 - .../composeResources/values-fa/strings.xml | 10 - .../composeResources/values-fr/strings.xml | 10 - .../composeResources/values-hi/strings.xml | 10 - .../composeResources/values-id/strings.xml | 10 - .../composeResources/values-it/strings.xml | 10 - .../composeResources/values-ja/strings.xml | 10 - .../composeResources/values-ko/strings.xml | 10 - .../composeResources/values-nl/strings.xml | 10 - .../composeResources/values-pl/strings.xml | 10 - .../composeResources/values-pt/strings.xml | 10 - .../composeResources/values-ru/strings.xml | 10 - .../composeResources/values-th/strings.xml | 10 - .../composeResources/values-tr/strings.xml | 10 - .../composeResources/values-uk/strings.xml | 10 - .../composeResources/values-vi/strings.xml | 10 - .../values-zh-rTW/strings.xml | 10 - .../composeResources/values-zh/strings.xml | 10 - .../composeResources/values/strings.xml | 23 +- .../core/discovery/AdvertiseAddress.kt | 27 + .../core/discovery/DeviceDiscoveryService.kt | 19 +- .../core/navigation/NavigationModule.kt | 2 +- .../core/remote/RemoteControlClient.kt | 71 +- .../spotube/modules/devices/DevicesScreen.kt | 465 ++++++--- .../modules/devices/DevicesViewModel.kt | 138 ++- .../modules/devices/RemoteControlScreen.kt | 986 +++++++++++------- .../modules/devices/RemoteControlViewModel.kt | 289 +++-- .../modules/settings/SettingsModels.kt | 1 - .../settings/sections/PlaybackSection.kt | 15 - .../core/discovery/AdvertiseAddress.ios.kt | 21 + .../core/discovery/AdvertiseAddress.jvm.kt | 53 + 35 files changed, 1385 insertions(+), 956 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt new file mode 100644 index 00000000..2f73a824 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +// The OS's own service-discovery stack advertises on the right network. +internal actual fun preferredAdvertiseAddress(): String? = null diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index 674b83a9..af2a5da6 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -225,8 +225,6 @@ يجب أن يكون الحجم 0 أو رقمًا موجبًا. تفعيل التشغيل اللانهائي واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار. - تفعيل Connect - إتاحة عناصر التحكم في التشغيل عن بُعد عبر ميزة Spotube Connect. منفذ خادم وكيل التشغيل السماح بالتحكم عن بُعد اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها. @@ -511,7 +509,6 @@ الأجهزة تحديث - متصل بـ %1$s:%2$d قطع الاتصال جارٍ الاتصال... جارٍ البحث عن أجهزة على الشبكة... @@ -519,20 +516,13 @@ تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر. جهاز غير معروف جارٍ التحديد... - المعرّف: %1$s... التحكم عن بُعد غير متصل خطأ في الاتصال: %1$s - غلاف الألبوم مقطع غير معروف فنان غير معروف - التقديم والترجيع - ترتيب عشوائي - السابق - التالي - وضع التكرار مستوى الصوت إزالة من قائمة الانتظار خيارات إضافية diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index 03b55163..cf54df53 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -213,8 +213,6 @@ সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে। অবিরাম প্লেব্যাক চালু করুন কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন। - Connect চালু করুন - Spotube Connect ফিচারের মাধ্যমে রিমোট প্লেব্যাক কন্ট্রোল উপলব্ধ করুন। প্লেব্যাক প্রক্সি সার্ভার পোর্ট রিমোট কন্ট্রোলের অনুমতি দিন একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন। @@ -495,7 +493,6 @@ ডিভাইস রিফ্রেশ - %1$s:%2$d-এ কানেক্টেড ডিসকানেক্ট কানেক্ট হচ্ছে... নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে... @@ -503,20 +500,13 @@ অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন। অজানা ডিভাইস খোঁজা হচ্ছে... - ID: %1$s... রিমোট কন্ট্রোল ডিসকানেক্টেড কানেকশন ত্রুটি: %1$s - অ্যালবাম কভার অজানা ট্র্যাক অজানা শিল্পী - সিক - শাফল - আগের - পরের - লুপ মোড ভলিউম কিউ থেকে সরান আরও অপশন diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 71f1278a..22f864c8 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -213,8 +213,6 @@ Die Größe muss 0 oder eine positive Zahl sein. Endlose Wiedergabe aktivieren Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter. - Connect aktivieren - Fernsteuerung der Wiedergabe über Spotube Connect bereitstellen. Port des Wiedergabe-Proxyservers Fernsteuerung erlauben Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden. @@ -495,7 +493,6 @@ Geräte Aktualisieren - Verbunden mit %1$s:%2$d Trennen Verbinden... Suche nach Geräten im Netzwerk... @@ -503,20 +500,13 @@ Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist. Unbekanntes Gerät Wird aufgelöst... - ID: %1$s... Fernsteuerung Getrennt Verbindungsfehler: %1$s - Albumcover Unbekannter Titel Unbekannter Künstler - Spulen - Zufallswiedergabe - Zurück - Weiter - Wiederholungsmodus Lautstärke Aus Warteschlange entfernen Weitere Optionen diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index f6322e76..10092442 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -216,8 +216,6 @@ El tamaño debe ser 0 o un número positivo. Activar reproducción infinita Sigue reproduciendo canciones recomendadas cuando termine la cola. - Activar Connect - Ofrece controles de reproducción remota mediante la función Spotube Connect. Puerto del servidor proxy de reproducción Permitir control remoto Permite que otros dispositivos de la misma red controlen la reproducción al conectarse. @@ -499,7 +497,6 @@ Dispositivos Actualizar - Conectado a %1$s:%2$d Desconectar Conectando... Buscando dispositivos en la red... @@ -507,20 +504,13 @@ Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes. Dispositivo desconocido Resolviendo... - ID: %1$s... Control remoto Desconectado Error de conexión: %1$s - Portada del álbum Canción desconocida Artista desconocido - Avanzar - Aleatorio - Anterior - Siguiente - Modo de repetición Volumen Quitar de la cola Más opciones diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 89556d91..84c45926 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -213,8 +213,6 @@ حجم باید 0 یا یک عدد مثبت باشد. فعال‌سازی پخش بی‌پایان وقتی صف پخش تمام شد، پخش با آهنگ‌های پیشنهادی ادامه پیدا کند. - فعال‌سازی Connect - کنترل‌های پخش از راه دور را از طریق قابلیت Spotube Connect در دسترس قرار دهید. درگاه سرور پراکسی پخش اجازهٔ کنترل از راه دور به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند. @@ -495,7 +493,6 @@ دستگاه‌ها به‌روزرسانی - متصل به %1$s:%2$d قطع اتصال در حال اتصال... در حال جستجوی دستگاه‌ها در شبکه... @@ -503,20 +500,13 @@ مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است. دستگاه ناشناس در حال شناسایی... - شناسه: %1$s... کنترل از راه دور قطع شد خطای اتصال: %1$s - کاور آلبوم آهنگ ناشناس هنرمند ناشناس - جابه‌جایی - پخش تصادفی - قبلی - بعدی - حالت تکرار صدا حذف از صف گزینه‌های بیشتر diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 9c4bf680..b5be84d8 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -216,8 +216,6 @@ La taille doit être 0 ou un nombre positif. Activer la lecture continue Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente. - Activer Connect - Proposer des commandes de lecture à distance via la fonctionnalité Spotube Connect. Port du serveur proxy de lecture Autoriser le contrôle à distance Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent. @@ -499,7 +497,6 @@ Appareils Actualiser - Connecté à %1$s:%2$d Se déconnecter Connexion... Recherche d\'appareils sur le réseau... @@ -507,20 +504,13 @@ Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil. Appareil inconnu Résolution... - ID : %1$s... Contrôle à distance Déconnecté Erreur de connexion : %1$s - Pochette d\'album Titre inconnu Artiste inconnu - Avancer/reculer - Lecture aléatoire - Précédent - Suivant - Mode de répétition Volume Retirer de la file d\'attente Plus d\'options diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 569467ed..15344dcd 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -212,8 +212,6 @@ साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए। लगातार प्लेबैक चालू करें कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें। - Connect चालू करें - Spotube Connect फ़ीचर के ज़रिए रिमोट प्लेबैक कंट्रोल उपलब्ध कराएं। प्लेबैक प्रॉक्सी सर्वर पोर्ट रिमोट कंट्रोल की अनुमति दें एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें। @@ -494,7 +492,6 @@ डिवाइस रीफ़्रेश करें - %1$s:%2$d से कनेक्ट है डिस्कनेक्ट करें कनेक्ट हो रहा है... नेटवर्क पर डिवाइस खोजे जा रहे हैं... @@ -502,20 +499,13 @@ पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है। अज्ञात डिवाइस पता लगाया जा रहा है... - ID: %1$s... रिमोट कंट्रोल डिस्कनेक्ट हो गया कनेक्शन में गड़बड़ी: %1$s - एल्बम कवर अज्ञात ट्रैक अज्ञात कलाकार - आगे-पीछे करें - शफ़ल - पिछला - अगला - रिपीट मोड वॉल्यूम कतार से हटाएं और विकल्प diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index fbc02ba5..f189a383 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -210,8 +210,6 @@ Ukuran harus 0 atau bilangan positif. Aktifkan pemutaran tanpa henti Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis. - Aktifkan Connect - Tampilkan kontrol pemutaran jarak jauh melalui fitur Spotube Connect. Port server proxy pemutaran Izinkan kontrol jarak jauh Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung. @@ -491,7 +489,6 @@ Perangkat Muat ulang - Terhubung ke %1$s:%2$d Putuskan Menghubungkan... Mencari perangkat di jaringan... @@ -499,20 +496,13 @@ Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan. Perangkat Tidak Dikenal Menyelesaikan... - ID: %1$s... Kontrol Jarak Jauh Terputus Kesalahan koneksi: %1$s - Sampul album Lagu Tidak Dikenal Artis Tidak Dikenal - Cari posisi - Acak - Sebelumnya - Berikutnya - Mode ulang Volume Hapus dari antrean Opsi lainnya diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 4a3475b6..67ce22bd 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -216,8 +216,6 @@ La dimensione deve essere 0 o un numero positivo. Attiva riproduzione continua Continua la riproduzione con brani consigliati quando la coda finisce. - Attiva Connect - Rendi disponibili i controlli di riproduzione remota tramite la funzione Spotube Connect. Porta del server proxy di riproduzione Consenti controllo remoto Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono. @@ -499,7 +497,6 @@ Dispositivi Aggiorna - Connesso a %1$s:%2$d Disconnetti Connessione... Ricerca di dispositivi sulla rete... @@ -507,20 +504,13 @@ Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni. Dispositivo sconosciuto Risoluzione... - ID: %1$s... Controllo remoto Disconnesso Errore di connessione: %1$s - Copertina dell\'album Brano sconosciuto Artista sconosciuto - Posizione - Riproduzione casuale - Precedente - Successivo - Modalità ripetizione Volume Rimuovi dalla coda Altre opzioni diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 9976483c..c01033a0 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -210,8 +210,6 @@ サイズは0または正の数で指定してください。 エンドレス再生を有効化 キューが終わったら、おすすめの曲で再生を続けます。 - Connectを有効化 - Spotube Connect機能でリモート再生コントロールを公開します。 再生プロキシサーバーのポート リモート操作を許可 同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。 @@ -491,7 +489,6 @@ デバイス 更新 - %1$s:%2$d に接続済み 切断 接続中... ネットワーク上のデバイスを検索中... @@ -499,20 +496,13 @@ 相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。 不明なデバイス 解決中... - ID: %1$s... リモート操作 切断されました 接続エラー: %1$s - アルバムカバー 不明な曲 不明なアーティスト - シーク - シャッフル - 前へ - 次へ - リピートモード 音量 キューから削除 その他のオプション diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index f0b8409e..1db8b5e5 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -210,8 +210,6 @@ 용량은 0 또는 양수여야 해요. 끝없는 재생 사용 대기열이 끝나면 추천 트랙으로 계속 재생해요. - Connect 사용 - Spotube Connect 기능을 통해 원격 재생 컨트롤을 제공해요. 재생 프록시 서버 포트 원격 제어 허용 같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요. @@ -491,7 +489,6 @@ 기기 새로고침 - %1$s:%2$d에 연결됨 연결 해제 연결 중... 네트워크에서 기기를 찾는 중... @@ -499,20 +496,13 @@ 상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요. 알 수 없는 기기 확인 중... - ID: %1$s... 원격 제어 연결 끊김 연결 오류: %1$s - 앨범 커버 알 수 없는 트랙 알 수 없는 아티스트 - 탐색 - 셔플 - 이전 - 다음 - 반복 모드 볼륨 대기열에서 삭제 옵션 더보기 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index 2c79ad97..b6ab0848 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -213,8 +213,6 @@ Grootte moet 0 of een positief getal zijn. Eindeloos afspelen inschakelen Blijf afspelen met aanbevolen nummers als je wachtrij leeg is. - Connect inschakelen - Maak bediening op afstand beschikbaar via Spotube Connect. Poort van afspeelproxyserver Bediening op afstand toestaan Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken. @@ -495,7 +493,6 @@ Apparaten Vernieuwen - Verbonden met %1$s:%2$d Verbinding verbreken Verbinden... Zoeken naar apparaten op het netwerk... @@ -503,20 +500,13 @@ Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat. Onbekend apparaat Bezig met ophalen... - ID: %1$s... Bediening op afstand Niet verbonden Verbindingsfout: %1$s - Albumhoes Onbekend nummer Onbekende artiest - Zoeken in nummer - Shuffle - Vorige - Volgende - Herhaalmodus Volume Verwijderen uit wachtrij Meer opties diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index ddc97d41..56dda439 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -219,8 +219,6 @@ Rozmiar musi wynosić 0 lub być liczbą dodatnią. Włącz nieskończone odtwarzanie Gdy kolejka się skończy, odtwarzaj dalej polecane utwory. - Włącz Connect - Udostępniaj sterowanie odtwarzaniem przez funkcję Spotube Connect. Port serwera proxy odtwarzania Zezwól na zdalne sterowanie Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu. @@ -503,7 +501,6 @@ Urządzenia Odśwież - Połączono z %1$s:%2$d Rozłącz Łączenie... Wyszukiwanie urządzeń w sieci... @@ -511,20 +508,13 @@ Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”. Nieznane urządzenie Ustalanie adresu... - ID: %1$s... Zdalne sterowanie Rozłączono Błąd połączenia: %1$s - Okładka albumu Nieznany utwór Nieznany wykonawca - Przewiń - Odtwarzanie losowe - Poprzedni - Następny - Tryb powtarzania Głośność Usuń z kolejki Więcej opcji diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 3694b5c2..95dcb260 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -216,8 +216,6 @@ O tamanho deve ser 0 ou um número positivo. Ativar reprodução contínua Continua tocando faixas recomendadas quando sua fila acabar. - Ativar Connect - Disponibiliza controles de reprodução remota pelo recurso Spotube Connect. Porta do servidor proxy de reprodução Permitir controle remoto Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem. @@ -499,7 +497,6 @@ Dispositivos Atualizar - Conectado a %1$s:%2$d Desconectar Conectando... Procurando dispositivos na rede... @@ -507,20 +504,13 @@ Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações. Dispositivo desconhecido Resolvendo... - ID: %1$s... Controle remoto Desconectado Erro de conexão: %1$s - Capa do álbum Faixa desconhecida Artista desconhecido - Avançar/retroceder - Ordem aleatória - Anterior - Próxima - Modo de repetição Volume Remover da fila Mais opções diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index 860089e6..2ce7ca5a 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -219,8 +219,6 @@ Размер должен быть 0 или положительным числом. Бесконечное воспроизведение Продолжать воспроизведение рекомендованными треками, когда очередь закончится. - Включить Connect - Предоставлять удалённое управление воспроизведением через Spotube Connect. Порт прокси-сервера воспроизведения Разрешить удалённое управление Разрешить другим устройствам в той же сети управлять воспроизведением после подключения. @@ -503,7 +501,6 @@ Устройства Обновить - Подключено к %1$s:%2$d Отключиться Подключение... Поиск устройств в сети... @@ -511,20 +508,13 @@ Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление». Неизвестное устройство Определение адреса... - ID: %1$s... Удалённое управление Отключено Ошибка подключения: %1$s - Обложка альбома Неизвестный трек Неизвестный исполнитель - Перемотка - Перемешать - Предыдущий - Следующий - Режим повтора Громкость Удалить из очереди Другие действия diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index dffc10f4..246fb93e 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -210,8 +210,6 @@ ขนาดต้องเป็น 0 หรือจำนวนบวก เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด - เปิดใช้ Connect - เปิดให้ควบคุมการเล่นจากระยะไกลผ่านฟีเจอร์ Spotube Connect พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น อนุญาตการควบคุมระยะไกล ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ @@ -491,7 +489,6 @@ อุปกรณ์ รีเฟรช - เชื่อมต่อกับ %1$s:%2$d แล้ว ยกเลิกการเชื่อมต่อ กำลังเชื่อมต่อ... กำลังค้นหาอุปกรณ์ในเครือข่าย... @@ -499,20 +496,13 @@ ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า อุปกรณ์ที่ไม่รู้จัก กำลังระบุ... - ID: %1$s... รีโมตคอนโทรล ยกเลิกการเชื่อมต่อแล้ว ข้อผิดพลาดในการเชื่อมต่อ: %1$s - ปกอัลบั้ม ไม่ทราบชื่อเพลง ไม่ทราบศิลปิน - เลื่อนตำแหน่ง - สุ่มเพลง - ก่อนหน้า - ถัดไป - โหมดเล่นซ้ำ ระดับเสียง นำออกจากคิว ตัวเลือกเพิ่มเติม diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 6f7bea1a..9024bbca 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -213,8 +213,6 @@ Boyut 0 veya pozitif bir sayı olmalı. Sonsuz çalmayı etkinleştir Sıran bittiğinde önerilen parçalarla çalmaya devam et. - Connect\'i etkinleştir - Uzaktan çalma kontrollerini Spotube Connect özelliğiyle sun. Çalma proxy sunucusu portu Uzaktan kontrole izin ver Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin. @@ -495,7 +493,6 @@ Cihazlar Yenile - %1$s:%2$d adresine bağlı Bağlantıyı kes Bağlanıyor... Ağda cihaz aranıyor... @@ -503,20 +500,13 @@ Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol. Bilinmeyen Cihaz Çözümleniyor... - Kimlik: %1$s... Uzaktan Kontrol Bağlantı kesildi Bağlantı hatası: %1$s - Albüm kapağı Bilinmeyen Parça Bilinmeyen Sanatçı - Konum - Karıştır - Önceki - Sonraki - Tekrar modu Ses düzeyi Sıradan kaldır Diğer seçenekler diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index 37273b97..cb9bf14f 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -219,8 +219,6 @@ Розмір має бути 0 або додатним числом. Безкінечне відтворення Продовжувати відтворення рекомендованими треками, коли черга закінчиться. - Увімкнути Connect - Надавати віддалене керування відтворенням через Spotube Connect. Порт проксі-сервера відтворення Дозволити віддалене керування Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення. @@ -503,7 +501,6 @@ Пристрої Оновити - Підключено до %1$s:%2$d Від’єднатися Підключення... Пошук пристроїв у мережі... @@ -511,20 +508,13 @@ Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування». Невідомий пристрій Визначення адреси... - ID: %1$s... Віддалене керування Від’єднано Помилка підключення: %1$s - Обкладинка альбому Невідомий трек Невідомий виконавець - Перемотування - Перемішати - Попередній - Наступний - Режим повтору Гучність Видалити з черги Інші дії diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 4b81d8d0..6a6b75fb 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -210,8 +210,6 @@ Dung lượng phải là 0 hoặc một số dương. Bật phát liên tục Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc. - Bật Connect - Cung cấp điều khiển phát từ xa qua tính năng Spotube Connect. Cổng máy chủ proxy phát lại Cho phép điều khiển từ xa Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối. @@ -491,7 +489,6 @@ Thiết bị Làm mới - Đã kết nối với %1$s:%2$d Ngắt kết nối Đang kết nối... Đang tìm thiết bị trong mạng... @@ -499,20 +496,13 @@ Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt. Thiết bị không xác định Đang phân giải... - ID: %1$s... Điều khiển từ xa Đã ngắt kết nối Lỗi kết nối: %1$s - Ảnh bìa album Bài hát không xác định Nghệ sĩ không xác định - Tua - Phát ngẫu nhiên - Trước - Tiếp - Chế độ lặp Âm lượng Xóa khỏi danh sách chờ Tùy chọn khác diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index 6ab50884..7aaee59d 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -210,8 +210,6 @@ 大小必須為 0 或正數。 啟用無限播放 待播清單播完後,以推薦曲目繼續播放。 - 啟用 Connect - 透過 Spotube Connect 功能提供遠端播放控制。 播放代理伺服器連接埠 允許遠端控制 讓同一網路中的其他裝置在連線後控制播放。 @@ -491,7 +489,6 @@ 裝置 重新整理 - 已連線至 %1$s:%2$d 中斷連線 連線中... 正在搜尋網路上的裝置... @@ -499,20 +496,13 @@ 請確認另一部裝置已在設定中啟用「允許遠端控制」。 未知裝置 解析中... - ID:%1$s... 遠端控制 已中斷連線 連線錯誤:%1$s - 專輯封面 未知曲目 未知藝人 - 播放進度 - 隨機播放 - 上一首 - 下一首 - 重複播放模式 音量 從待播清單移除 更多選項 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index e95e0b45..1caaa3c2 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -210,8 +210,6 @@ 大小必须为 0 或正数。 启用无尽播放 队列播放完毕后,继续播放推荐的歌曲。 - 启用 Connect - 通过 Spotube Connect 功能开放远程播放控制。 播放代理服务器端口 允许远程控制 允许同一网络中的其他设备在连接后控制播放。 @@ -491,7 +489,6 @@ 设备 刷新 - 已连接到 %1$s:%2$d 断开连接 正在连接... 正在搜索网络中的设备... @@ -499,20 +496,13 @@ 请确保另一台设备已在设置中启用“允许远程控制”。 未知设备 正在解析... - ID:%1$s... 远程控制 已断开连接 连接错误:%1$s - 专辑封面 未知歌曲 未知艺人 - 进度 - 随机播放 - 上一首 - 下一首 - 循环模式 音量 从队列中移除 更多选项 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e4d68e93..f7acd6a9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -215,8 +215,6 @@ Size must be 0 or a positive number. Enable endless playback Keep playback going with recommended tracks when your queue ends. - Enable Connect - Expose remote playback controls through the Spotube Connect feature. Playback proxy server port Allow remote control Let other devices on the same network control playback when they connect. @@ -504,6 +502,7 @@ Connection failed Connection lost + The other device declined the request, or has remote control turned off. Send failed Discovery failed: %1$s Failed to connect: %1$s @@ -511,7 +510,6 @@ Devices Refresh - Connected to %1$s:%2$d Disconnect Connecting... Searching for devices on the network... @@ -519,23 +517,28 @@ Make sure the other device has \"Allow remote control\" enabled in settings. Unknown Device Resolving... - ID: %1$s... + Connected + Available devices + Connected + Open remote + Search again + Still looking for more devices... Remote Control Disconnected Connection error: %1$s - Album cover Unknown Track Unknown Artist - Seek - Shuffle - Previous - Next - Loop mode Volume Remove from queue More options + Playing on %1$s + If asked, allow this device on the other Spotube first. + You\'re no longer controlling the other device. Pick it again to reconnect. + Back to devices + Nothing playing + Start something on the other device, or pick a track from its queue. Play Where? diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt new file mode 100644 index 00000000..d67d8efb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt @@ -0,0 +1,27 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +/** + * The LAN address to publish in our mDNS advertisement, or null to let the platform decide. + * + * Only the desktop needs this: there the dns-sd library otherwise grabs the first site-local + * address of *any* interface, which on a dev box is often a Docker/VM bridge (e.g. minikube's + * 192.168.49.1) that other devices can't reach. + */ +internal expect fun preferredAdvertiseAddress(): String? diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt index 31d5c2d3..b0c0c2f2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt @@ -21,8 +21,12 @@ import com.appstractive.dnssd.DiscoveryEvent import com.appstractive.dnssd.NetService import com.appstractive.dnssd.createNetService import com.appstractive.dnssd.discoverServices +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import kotlin.text.decodeToString data class DiscoveredDevice( @@ -35,7 +39,8 @@ data class DiscoveredDevice( } sealed interface DiscoveryState { - data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState + /** [resolve] can block for seconds on desktop (JmDNS), so it's a suspend call that runs off the main thread. */ + data class Discovered(val device: DiscoveredDevice, val resolve: suspend () -> Unit) : DiscoveryState data class Resolved(val device: DiscoveredDevice) : DiscoveryState data class Removed(val device: DiscoveredDevice) : DiscoveryState } @@ -46,6 +51,11 @@ class DeviceDiscoveryService { const val TXT_DEVICE_ID = "deviceId" } + /** + * Runs on [Dispatchers.IO]: on desktop, cancelling the underlying flow calls the blocking + * `JmDNS.close()` (it waits up to 5s for its shutdown), which froze the UI whenever the + * collector was cancelled on the main thread. + */ fun discover(): Flow = discoverServices(SERVICE_TYPE).map { event -> when (event) { is DiscoveryEvent.Discovered -> { @@ -55,7 +65,9 @@ class DeviceDiscoveryService { host = event.service.host, port = event.service.port, deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) - DiscoveryState.Discovered(device = device, resolve = event.resolve) + DiscoveryState.Discovered( + device = device, + resolve = { withContext(Dispatchers.IO) { event.resolve() } }) } is DiscoveryEvent.Resolved -> { @@ -78,7 +90,7 @@ class DeviceDiscoveryService { DiscoveryState.Removed(device = device) } } - } + }.flowOn(Dispatchers.IO) suspend fun advertise( name: String, @@ -97,5 +109,6 @@ class DeviceDiscoveryService { type = SERVICE_TYPE, name = name, port = port, + addresses = preferredAdvertiseAddress()?.let { listOf(it) }, txt = mapOf(TXT_DEVICE_ID to deviceId)) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt index aef9d397..0410a281 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -192,7 +192,7 @@ val navigationModule = module { BlacklistScreen() } navigation { - DevicesScreen(navigationCommands = get()) + DevicesScreen() } navigation { RemoteControlScreen( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt index f4894ede..ed7dce2e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt @@ -26,7 +26,7 @@ import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame -import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.DefaultWebSocketSession import io.ktor.websocket.close import io.ktor.websocket.readText import kotlinx.coroutines.CoroutineScope @@ -40,6 +40,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.jetbrains.compose.resources.StringResource import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_error_connection_denied import spotube.composeapp.generated.resources.devices_error_connection_failed import spotube.composeapp.generated.resources.devices_error_connection_lost import spotube.composeapp.generated.resources.devices_error_send_failed @@ -66,7 +67,7 @@ class RemoteControlClient { } } - private var session: WebSocketSession? = null + private var session: DefaultWebSocketSession? = null private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() @@ -77,12 +78,23 @@ class RemoteControlClient { private val _latestQueue = MutableStateFlow(null) val latestQueue: StateFlow = _latestQueue.asStateFlow() - suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { + /** + * [deviceName] identifies this controller to the host; [remoteDeviceName] is the + * host's advertised name, kept only so the UI can show what it is controlling. + */ + suspend fun connect( + host: String, + port: Int, + deviceId: String, + deviceName: String, + remoteDeviceName: String = "") { if (_connectionState.value is ConnectionState.Connected) { logger.w { "Already connected" } return } + // Drop a stale attempt (e.g. one still waiting on the host's permission prompt). + closeSession() _connectionState.value = ConnectionState.Connecting try { session = httpClient.webSocketSession { @@ -95,16 +107,16 @@ class RemoteControlClient { // Start receiving messages in a separate coroutine scope.launch { - receiveLoop(host, port) + receiveLoop(host, port, remoteDeviceName) } } catch (e: Exception) { logger.e(e) { "Failed to connect to $host:$port" } + closeSession() _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed) - disconnect() } } - private suspend fun receiveLoop(host: String, port: Int) { + private suspend fun receiveLoop(host: String, port: Int, remoteDeviceName: String) { val currentSession = session ?: return logger.d { "Starting receive loop for $host:$port" } try { @@ -119,7 +131,7 @@ class RemoteControlClient { when (event) { is RemoteControlEvent.Connected -> { logger.i { "Connection authorized by server" } - _connectionState.value = ConnectionState.Connected(host, port) + _connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName) } is RemoteControlEvent.WaitingForPermission -> { logger.i { "Waiting for permission: ${event.message}" } @@ -144,18 +156,37 @@ class RemoteControlClient { logger.w(e) { "Failed to parse message: $text" } } } - is Frame.Close -> { - logger.i { "WebSocket closed by server" } - _connectionState.value = ConnectionState.Disconnected - break - } else -> {} } } - logger.d { "Receive loop exited normally" } + // Ktor consumes Close frames itself and just ends `incoming`, so the server + // closing the socket (denied request, remote control turned off, host quit) + // only shows up here. Without this the state stayed Connecting/Connected forever + // and every later connect attempt was swallowed. + val reason = currentSession.closeReason.await() + logger.i { "WebSocket closed by server: $reason" } + onServerClosed(currentSession, reason) } catch (e: Exception) { logger.e(e) { "Error in receive loop" } - _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost) + if (session === currentSession) { + session = null + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost) + } + } + } + + private fun onServerClosed(closedSession: DefaultWebSocketSession, reason: CloseReason?) { + // disconnect() (or a newer connect()) already replaced this session; nothing to report. + if (session !== closedSession) return + session = null + val wasAuthorized = _connectionState.value is ConnectionState.Connected + _connectionState.value = when { + // The host refuses with VIOLATED_POLICY before authorizing: the user hit Deny + // (or dismissed the prompt), or remote control is turned off over there. + !wasAuthorized && reason?.knownReason == CloseReason.Codes.VIOLATED_POLICY -> + ConnectionState.Error(null, Res.string.devices_error_connection_denied) + wasAuthorized -> ConnectionState.Disconnected + else -> ConnectionState.Error(reason?.message?.ifBlank { null }, Res.string.devices_error_connection_failed) } } @@ -180,12 +211,18 @@ class RemoteControlClient { } suspend fun disconnect() { - session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) - session = null + closeSession() _connectionState.value = ConnectionState.Disconnected logger.i { "Disconnected" } } + /** Detaches before closing so the receive loop doesn't report this as a server close. */ + private suspend fun closeSession() { + val current = session ?: return + session = null + runCatching { current.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) } + } + private fun randomShortId(): String { val chars = "0123456789abcdef" return buildString(8) { @@ -199,7 +236,7 @@ class RemoteControlClient { sealed interface ConnectionState { data object Disconnected : ConnectionState data object Connecting : ConnectionState - data class Connected(val host: String, val port: Int) : ConnectionState + data class Connected(val host: String, val port: Int, val deviceName: String = "") : ConnectionState /** * [message] is the raw (untranslated) cause when available; [fallback] is the * localized description to show when it isn't. diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt index ea92bd68..aef1a96f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -18,216 +18,195 @@ package org.spotube.spotube.modules.devices -import org.spotube.millennium.composables.MillenniumSpokeSpinner -import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.CircleShape import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.spotube.spotube.core.discovery.DiscoveredDevice import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester -import org.spotube.spotube.core.navigation.NavigationCommands -import org.spotube.spotube.core.remote.ConnectionState import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.settingsSectionCard +import org.spotube.spotube.modules.settings.sections.settingsSectionHeader import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxInformation import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.devices_connected_to -import spotube.composeapp.generated.resources.devices_connecting -import spotube.composeapp.generated.resources.devices_device_id import spotube.composeapp.generated.resources.devices_disconnect import spotube.composeapp.generated.resources.devices_empty import spotube.composeapp.generated.resources.devices_empty_hint +import spotube.composeapp.generated.resources.devices_open_remote import spotube.composeapp.generated.resources.devices_refresh import spotube.composeapp.generated.resources.devices_resolving +import spotube.composeapp.generated.resources.devices_search_again import spotube.composeapp.generated.resources.devices_searching +import spotube.composeapp.generated.resources.devices_searching_more +import spotube.composeapp.generated.resources.devices_section_available +import spotube.composeapp.generated.resources.devices_section_connected +import spotube.composeapp.generated.resources.devices_status_connected import spotube.composeapp.generated.resources.devices_title import spotube.composeapp.generated.resources.devices_unknown_device import spotube.composeapp.generated.resources.jam_error_dismiss +private val ContentMaxWidth = 720.dp + @Composable fun DevicesScreen( - navigationCommands: NavigationCommands) { - val viewModel = koinViewModel() - val devices by viewModel.devices.collectAsStateWithLifecycle() - val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() - val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle() - val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - val error by viewModel.error.collectAsStateWithLifecycle() + viewModel: DevicesViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val shellBottomInset = LocalAppShellBottomInset.current DisposableEffect(Unit) { // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. requestLocalNetworkPermission() viewModel.startDiscovery() onDispose { + // The remote connection itself outlives this screen; only discovery stops. viewModel.stopDiscovery() - // Don't disconnect here - the connection should persist when navigating to RemoteControlScreen - // The RemoteControlViewModel will manage the connection lifecycle } } Column( modifier = Modifier .fillMaxSize() - .background(MillenniumTheme.colors.background)) { + .background(MillenniumTheme.colors.background) + ) { ApplicationMainBar( backButton = true, title = { Text(stringResource(Res.string.devices_title)) }, actions = { - if (isDiscovering && connectingToDevice == null) { + if (state.isDiscovering) { MillenniumSpokeSpinner( - size = 24.dp, - modifier = Modifier.padding(end = 8.dp)) + size = 20.dp, + modifier = Modifier.padding(horizontal = 10.dp) + ) } else { - MillenniumIcon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = stringResource(Res.string.devices_refresh), - modifier = Modifier - .size(24.dp) - .clickable(enabled = connectingToDevice == null) { - viewModel.startDiscovery() - }) - } - }) - val shellBottomInset = LocalAppShellBottomInset.current - - Column( - modifier = Modifier - .fillMaxSize() - .weight(1f)) { - // Error banner - error?.let { devicesError -> - val errorMessage = devicesError.rawMessage - ?: stringResource(devicesError.message, *devicesError.args.toTypedArray()) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp)) { - Column { - Text( - text = errorMessage, - color = AppTheme.colors.error, - style = AppTheme.typography.bodyMedium) - MillenniumButton( - onClick = { viewModel.clearError() }, - modifier = Modifier.padding(top = 8.dp), - style = MillenniumButtonStyle.Secondary) { - Text(stringResource(Res.string.jam_error_dismiss)) - } + MillenniumIconButton( + onClick = viewModel::startDiscovery, + enabled = !state.isConnecting, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.devices_refresh) + ) } } } + ) - // Connection status - when (val state = connectionState) { - is ConnectionState.Connected -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp)) { - Column { - Text( - text = stringResource(Res.string.devices_connected_to, state.host, state.port), - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.primary) - MillenniumButton( - onClick = { viewModel.disconnect() }, - modifier = Modifier.padding(top = 8.dp), - style = MillenniumButtonStyle.Secondary) { - Text(stringResource(Res.string.devices_disconnect)) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + LazyColumn( + modifier = Modifier + .widthIn(max = ContentMaxWidth) + .fillMaxSize(), + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + top = 8.dp, + bottom = shellBottomInset + 16.dp + ) + ) { + state.error?.let { error -> + item { + ErrorBanner( + message = error.rawMessage + ?: stringResource(error.message, *error.args.toTypedArray()), + onDismiss = viewModel::clearError + ) + } + } + + state.connectedDevice?.let { device -> + settingsSectionHeader(Res.string.devices_section_connected) + item { + ConnectedDeviceCard( + device = device, + onOpenRemote = viewModel::openRemote, + onDisconnect = viewModel::disconnect + ) + } + } + + if (state.devices.isNotEmpty()) { + settingsSectionHeader(Res.string.devices_section_available) + settingsSectionCard( + items = state.devices.map { device -> + { + DeviceTile( + device = device, + enabled = !state.isConnecting || device.isConnecting, + onClick = { viewModel.onDeviceClick(device.key) } + ) } } - } - } - is ConnectionState.Connecting -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.CenterStart) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically) { - MillenniumSpokeSpinner(size = 24.dp) + ) + if (state.isDiscovering) { + item { Text( - text = stringResource(Res.string.devices_connecting), - style = AppTheme.typography.bodyMedium) - } - } - } - else -> {} - } - - // Device list - if (devices.isEmpty() && connectingToDevice == null) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = if (isDiscovering) { - stringResource(Res.string.devices_searching) - } else { - stringResource(Res.string.devices_empty) - }, - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) - if (!isDiscovering) { - Text( - text = stringResource(Res.string.devices_empty_hint), + text = stringResource(Res.string.devices_searching_more), style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp)) + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) } } - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 16.dp, - vertical = 8.dp)) { - items(devices.values.toList(), key = { it.key }) { device -> - DeviceRow( - device = device, - isConnecting = connectingToDevice?.key == device.key, - onClick = { viewModel.connectToDevice(device) }) - } + } else if (state.showEmptyState) { item { - Box(modifier = Modifier.padding(bottom = shellBottomInset)) + EmptyState( + isDiscovering = state.isDiscovering, + onSearchAgain = viewModel::startDiscovery + ) } } } @@ -236,48 +215,190 @@ fun DevicesScreen( } @Composable -private fun DeviceRow( - device: DiscoveredDevice, - isConnecting: Boolean, - onClick: () -> Unit) { - Row( +private fun DeviceTile( + device: DeviceItemUi, + enabled: Boolean, + onClick: () -> Unit +) { + val name = device.name ?: stringResource(Res.string.devices_unknown_device) + SettingCardItem( + enabled = enabled, + title = name, + subtitle = device.address ?: stringResource(Res.string.devices_resolving), + icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) }, + trailingContent = { + when { + device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp) + device.isConnected -> Text( + text = stringResource(Res.string.devices_status_connected), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.primary + ) + else -> MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant + ) + } + }, + onClick = onClick + ) +} + +@Composable +private fun LazyItemScope.ConnectedDeviceCard( + device: ConnectedDeviceUi, + onOpenRemote: () -> Unit, + onDisconnect: () -> Unit +) { + val name = device.name ?: stringResource(Res.string.devices_unknown_device) + MillenniumCard( modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick, enabled = !isConnecting) - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (isConnecting) { - MillenniumSpokeSpinner(size = 24.dp) - } else { - MillenniumIcon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = null, - tint = AppTheme.colors.primary) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = device.name.ifBlank { stringResource(Res.string.devices_unknown_device) }, - style = AppTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis) - Text( - text = if (device.host.isNotBlank() && device.port > 0) { - "${device.host}:${device.port}" - } else { - stringResource(Res.string.devices_resolving) - }, - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis) - if (device.deviceId.isNotBlank()) { + .fillParentMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + maxWidth = ContentMaxWidth, + contentPadding = PaddingValues(16.dp), + contentSpacing = 16.dp, + shadowElevation = 6.dp + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) + Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(Res.string.devices_device_id, device.deviceId.take(8)), - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f), + text = name, + style = AppTheme.typography.titleMedium, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) + Text( + text = device.address, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MillenniumButton( + onClick = onDisconnect, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.devices_disconnect)) + } + MillenniumButton( + onClick = onOpenRemote, + style = MillenniumButtonStyle.Primary + ) { + Text(stringResource(Res.string.devices_open_remote)) + } + } + } +} + +@Composable +private fun ErrorBanner( + message: String, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clip(AppTheme.shapes.medium) + .background(AppTheme.colors.errorContainer) + .padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxInformation, + contentDescription = null, + tint = AppTheme.colors.onErrorContainer, + modifier = Modifier.size(20.dp) + ) + Text( + text = message, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onErrorContainer, + modifier = Modifier.weight(1f) + ) + MillenniumIconButton( + onClick = onDismiss, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.jam_error_dismiss), + tint = AppTheme.colors.onErrorContainer + ) + } + } +} + +@Composable +private fun LazyItemScope.EmptyState( + isDiscovering: Boolean, + onSearchAgain: () -> Unit +) { + Box( + modifier = Modifier + .fillParentMaxHeight(0.8f) + .fillParentMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + if (isDiscovering) { + MillenniumSpokeSpinner(size = 32.dp) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxWifiSquare, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(32.dp) + ) + } + } + Text( + text = stringResource( + if (isDiscovering) Res.string.devices_searching else Res.string.devices_empty + ), + style = AppTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = stringResource(Res.string.devices_empty_hint), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (!isDiscovering) { + MillenniumButton( + onClick = onSearchAgain, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(stringResource(Res.string.devices_search_again)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt index 65f59b5c..e9c24fa4 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -29,10 +29,13 @@ import org.spotube.spotube.core.remote.ConnectionState import org.spotube.spotube.core.remote.RemoteControlClient import org.spotube.spotube.core.remote.RemoteControlService import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -52,6 +55,28 @@ data class DevicesError( val args: List = emptyList(), val rawMessage: String? = null) +data class DeviceItemUi( + val key: String, + /** Null when the device advertised a blank name. */ + val name: String?, + /** `host:port`, or null while the address is still being resolved. */ + val address: String?, + val isConnecting: Boolean, + val isConnected: Boolean) + +data class ConnectedDeviceUi( + val name: String?, + val address: String) + +data class DevicesUiState( + val devices: List = emptyList(), + val isDiscovering: Boolean = false, + val isConnecting: Boolean = false, + val connectedDevice: ConnectedDeviceUi? = null, + val error: DevicesError? = null) { + val showEmptyState: Boolean get() = devices.isEmpty() && !isConnecting +} + class DevicesViewModel( private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { private val logger = Logger.withTag("DevicesViewModel") @@ -61,36 +86,63 @@ class DevicesViewModel( private val settingsProvider: SettingsProvider by inject() private val _devices = MutableStateFlow>(emptyMap()) - val devices: StateFlow> = _devices.asStateFlow() - private val _isDiscovering = MutableStateFlow(false) - val isDiscovering: StateFlow = _isDiscovering.asStateFlow() - private val _connectingToDevice = MutableStateFlow(null) - val connectingToDevice: StateFlow = _connectingToDevice.asStateFlow() - - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - val connectionState: StateFlow = _connectionState.asStateFlow() - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() + + val uiState: StateFlow = combine( + _devices, + _isDiscovering, + _connectingToDevice, + remoteControlClient.connectionState, + _error + ) { devices, isDiscovering, connectingTo, connection, error -> + val connected = connection as? ConnectionState.Connected + DevicesUiState( + devices = devices.values.map { device -> + DeviceItemUi( + key = device.key, + name = device.name.ifBlank { null }, + address = device.address(), + isConnecting = connectingTo?.key == device.key, + isConnected = connected != null && device.matches(connected)) + }, + isDiscovering = isDiscovering, + isConnecting = connectingTo != null, + connectedDevice = connected?.let { + ConnectedDeviceUi( + name = it.deviceName.ifBlank { null }, + address = "${it.host}:${it.port}") + }, + error = error) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) private var discoveryJob: Job? = null init { - // Observe connection state from the client viewModelScope.launch { + var previous: ConnectionState = remoteControlClient.connectionState.value remoteControlClient.connectionState.collect { state -> - _connectionState.value = state - if (state is ConnectionState.Error) { - _error.value = DevicesError(message = state.fallback, rawMessage = state.message) - _connectingToDevice.value = null - } else if (state is ConnectionState.Disconnected) { - _connectingToDevice.value = null - } else if (state is ConnectionState.Connected) { - // Navigate to remote control screen after successful connection - _connectingToDevice.value = null - navigationCommands.navigateTo(Routes.RemoteControl) + val wasConnecting = previous is ConnectionState.Connecting + previous = state + when (state) { + is ConnectionState.Error -> { + _error.value = DevicesError(message = state.fallback, rawMessage = state.message) + _connectingToDevice.value = null + } + // Only a drop *during* an attempt means it failed. Switching devices + // disconnects the old one first, and that must not cancel the new attempt. + is ConnectionState.Disconnected -> if (wasConnecting) _connectingToDevice.value = null + is ConnectionState.Connected -> { + // Only navigate for a connection this screen started. A connection + // that already existed when the screen opened must not bounce the + // user straight to the remote screen. + if (_connectingToDevice.value != null) { + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + is ConnectionState.Connecting -> {} } } } @@ -119,7 +171,8 @@ class DevicesViewModel( logger.d { "Discovery event: $event" } when (event) { is DiscoveryState.Discovered -> { - event.resolve() + // Don't hold up the rest of the events while this device resolves. + launch { event.resolve() } if (!isSelf(event.device)) { _devices.update { it + (event.device.key to event.device.copy()) } } @@ -137,6 +190,9 @@ class DevicesViewModel( } } } + } catch (e: CancellationException) { + // stopDiscovery() (e.g. leaving the screen) is not a failure. + throw e } catch (e: Exception) { logger.e(e) { "Discovery failed" } _error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString())) @@ -175,7 +231,25 @@ class DevicesViewModel( _isDiscovering.value = false } - fun connectToDevice(device: DiscoveredDevice) { + fun onDeviceClick(key: String) { + val device = _devices.value[key] ?: return + val connected = remoteControlClient.connectionState.value as? ConnectionState.Connected + when { + // Already controlling it (e.g. came back from the remote screen): just reopen it. + connected != null && device.matches(connected) -> openRemote() + connected != null -> viewModelScope.launch { + remoteControlClient.disconnect() + connectToDevice(device) + } + else -> connectToDevice(device) + } + } + + fun openRemote() { + navigationCommands.navigateTo(Routes.RemoteControl) + } + + private fun connectToDevice(device: DiscoveredDevice) { if (_connectingToDevice.value != null) { logger.w { "Already connecting to a device" } return @@ -200,11 +274,11 @@ class DevicesViewModel( host = device.host, port = device.port, deviceId = deviceId, - deviceName = deviceName) - - // Clear connecting state after connection attempt - // The connectionState flow will show the actual connection status - _connectingToDevice.value = null + deviceName = deviceName, + remoteDeviceName = device.name) + // connect() returns once the socket is open, but the host may still be + // asking its user for permission. The connection-state collector clears + // the connecting flag once the outcome is known. } catch (e: Exception) { logger.e(e) { "Failed to connect to device" } _error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString())) @@ -223,3 +297,9 @@ class DevicesViewModel( _error.value = null } } + +private fun DiscoveredDevice.address(): String? = + if (host.isNotBlank() && port > 0) "$host:$port" else null + +private fun DiscoveredDevice.matches(connected: ConnectionState.Connected): Boolean = + host == connected.host && port == connected.port diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt index bf67dcc8..765df60b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt @@ -18,35 +18,40 @@ package org.spotube.spotube.modules.devices +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumDropdownMenuItem -import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumSlider -import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -61,475 +66,675 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import org.spotube.spotube.core.remote.ConnectionState -import org.spotube.spotube.core.remote.RemoteQueueEntry import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.player_queue.QueueSheet import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxMusic import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove import org.spotube.spotube.resources.iconsax.IconsaxNext import org.spotube.spotube.resources.iconsax.IconsaxPause import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.devices_connecting import spotube.composeapp.generated.resources.devices_disconnect -import spotube.composeapp.generated.resources.devices_remote_album_cover +import spotube.composeapp.generated.resources.devices_remote_back_to_devices +import spotube.composeapp.generated.resources.devices_remote_connecting_hint import spotube.composeapp.generated.resources.devices_remote_connection_error import spotube.composeapp.generated.resources.devices_remote_disconnected -import spotube.composeapp.generated.resources.devices_remote_loop_mode +import spotube.composeapp.generated.resources.devices_remote_disconnected_hint import spotube.composeapp.generated.resources.devices_remote_more_options -import spotube.composeapp.generated.resources.devices_remote_next -import spotube.composeapp.generated.resources.devices_remote_previous +import spotube.composeapp.generated.resources.devices_remote_nothing_playing +import spotube.composeapp.generated.resources.devices_remote_nothing_playing_hint +import spotube.composeapp.generated.resources.devices_remote_playing_on import spotube.composeapp.generated.resources.devices_remote_remove_from_queue -import spotube.composeapp.generated.resources.devices_remote_seek -import spotube.composeapp.generated.resources.devices_remote_shuffle import spotube.composeapp.generated.resources.devices_remote_title import spotube.composeapp.generated.resources.devices_remote_unknown_artist import spotube.composeapp.generated.resources.devices_remote_unknown_track import spotube.composeapp.generated.resources.devices_remote_volume +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_next_content_description import spotube.composeapp.generated.resources.player_pause_content_description import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label import spotube.composeapp.generated.resources.queue_no_queue_entries import spotube.composeapp.generated.resources.queue_title -import kotlin.time.Duration.Companion.milliseconds + +// Below this content width the queue moves into a sheet; above it, it sits beside the player. +private val WideLayoutMinWidth = 760.dp +private val QueuePanelWidth = 380.dp +private val NowPlayingMaxWidth = 520.dp + +// Rough height of everything under the artwork (track info, seek bar, controls, volume). +// The artwork gets what's left, so the whole player fits without scrolling. +private val ControlsReservedHeight = 330.dp +private val MinArtworkSize = 140.dp +private val MaxArtworkSize = 460.dp @Composable fun RemoteControlScreen( - onDisconnect: () -> Unit + onDisconnect: () -> Unit, + viewModel: RemoteControlViewModel = koinViewModel() ) { - val viewModel = koinViewModel() - val playerState by viewModel.playerState.collectAsStateWithLifecycle() - val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - val queueState by viewModel.queueState.collectAsStateWithLifecycle() - val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() + val state by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current + val isConnected = state.connection is ConnectionState.Connected - // Center the mobile-inspired layout and limit its width so it doesn't - // stretch awkwardly on large screens. - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) ) { - Column( - modifier = Modifier - .fillMaxHeight() - .background(MillenniumTheme.colors.background) - ) { + val isWide = maxWidth >= WideLayoutMinWidth + + Column(modifier = Modifier.fillMaxSize()) { ApplicationMainBar( title = { Text(stringResource(Res.string.devices_remote_title)) }, + subtitle = { + if (isConnected && state.deviceName.isNotBlank()) { + Text(stringResource(Res.string.devices_remote_playing_on, state.deviceName)) + } + }, backButton = true, actions = { - MillenniumIconButton( - onClick = viewModel::toggleQueueVisibility, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = stringResource(Res.string.queue_title), - tint = if (isQueueVisible) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - } - ) + if (isConnected && !isWide) { + MillenniumIconButton( + onClick = viewModel::toggleQueueVisibility, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = stringResource(Res.string.queue_title), + tint = if (state.isQueueVisible) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } } - MillenniumIconButton( - onClick = { - viewModel.disconnect() - onDisconnect() - }, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = stringResource(Res.string.devices_disconnect) - ) + if (isConnected) { + MillenniumIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.devices_disconnect) + ) + } } } ) - Box(modifier = Modifier.weight(1f)) { - when (connectionState) { + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(bottom = shellBottomInset) + ) { + when (val connection = state.connection) { is ConnectionState.Connected -> { - RemoteControlContent( - playerState = playerState, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onSetVolume = viewModel::setVolume, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(bottom = shellBottomInset) + if (isWide) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + MillenniumCard( + modifier = Modifier + .width(QueuePanelWidth) + .fillMaxHeight(), + maxWidth = QueuePanelWidth, + contentPadding = PaddingValues(12.dp), + shadowElevation = 2.dp + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) + } + } + } else { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier.fillMaxSize() + ) + } + } + + is ConnectionState.Connecting -> ConnectionStatus( + title = stringResource(Res.string.devices_connecting), + message = stringResource(Res.string.devices_remote_connecting_hint), + leading = { MillenniumSpokeSpinner(size = 40.dp) } + ) + + is ConnectionState.Disconnected -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource(Res.string.devices_remote_disconnected_hint), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = false) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + + is ConnectionState.Error -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource( + Res.string.devices_remote_connection_error, + connection.message ?: stringResource(connection.fallback) + ), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = true) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + } + } + } + + if (isConnected && !isWide) { + // Click-outside scrim for the sliding drawer variant of the queue sheet. + // (The bottom sheet variant has its own built-in scrim.) + if (state.isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = viewModel::dismissQueue ) - } - - is ConnectionState.Connecting -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text(stringResource(Res.string.devices_connecting)) - } - } - - is ConnectionState.Disconnected -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text(stringResource(Res.string.devices_remote_disconnected)) - } - } - - is ConnectionState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - val errorState = connectionState as ConnectionState.Error - Text( - stringResource( - Res.string.devices_remote_connection_error, - errorState.message ?: stringResource(errorState.fallback))) - } - } + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset) + ) { + QueueSheet( + isVisible = state.isQueueVisible, + onDismiss = viewModel::dismissQueue, + modifier = Modifier.fillMaxSize() + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) } } } } +} - // Click-outside scrim for the sliding queue sheet on large screens. - // (The ModalBottomSheet variant has its own built-in scrim.) - if (isQueueVisible) { - Box( +@Composable +private fun NowPlayingPane( + state: RemoteControlUiState, + viewModel: RemoteControlViewModel, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier) { + val artworkSize = minOf( + maxWidth - 32.dp, + maxHeight - ControlsReservedHeight, + MaxArtworkSize + ).coerceAtLeast(MinArtworkSize) + + // Centered when it fits; scrolls only on very short windows. + Column( modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { viewModel.toggleQueueVisibility() }) - ) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier.widthIn(max = NowPlayingMaxWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Artwork( + coverUrl = state.coverUrl, + contentDescription = state.title, + size = artworkSize + ) + TrackInfo( + state = state, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) + SeekBar( + state = state, + onSeekChange = viewModel::onSeekChange, + onSeekFinished = viewModel::onSeekFinished, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + ) + PlaybackControls( + state = state, + onToggleShuffle = viewModel::toggleShuffle, + onSkipPrevious = viewModel::skipPrevious, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 12.dp) + ) + VolumeControl( + state = state, + onVolumeChange = viewModel::onVolumeChange, + onVolumeChangeFinished = viewModel::onVolumeChangeFinished, + modifier = Modifier.fillMaxWidth() + ) + } + } } +} - // Keep the sliding sheet above the AppLargePlayer on large screens. +@Composable +private fun Artwork( + coverUrl: String?, + contentDescription: String?, + size: Dp +) { Box( modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset) + .size(size) + .clip(RoundedCornerShape(22.dp)) + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center ) { - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize() - ) { - RemoteQueueSection( - queueState = queueState, - onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusic, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(56.dp) ) } } } @Composable -private fun RemoteControlContent( - playerState: RemotePlayerState, +private fun TrackInfo( + state: RemoteControlUiState, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (!state.hasTrack) { + Text( + text = stringResource(Res.string.devices_remote_nothing_playing), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1 + ) + Text( + text = stringResource(Res.string.devices_remote_nothing_playing_hint), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + return@Column + } + + Text( + text = state.title ?: stringResource(Res.string.devices_remote_unknown_track), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = state.artists ?: stringResource(Res.string.devices_remote_unknown_artist), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + state.album?.let { album -> + Text( + text = album, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} + +@Composable +private fun SeekBar( + state: RemoteControlUiState, + onSeekChange: (Float) -> Unit, + onSeekFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + MillenniumSlider( + value = state.seekProgress, + onValueChange = onSeekChange, + onValueChangeFinished = onSeekFinished, + enabled = state.canSeek, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = state.positionLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = state.durationLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } +} + +@Composable +private fun PlaybackControls( + state: RemoteControlUiState, + onToggleShuffle: () -> Unit, + onSkipPrevious: () -> Unit, onTogglePlayPause: () -> Unit, onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onSetVolume: (Float) -> Unit, - onToggleShuffle: () -> Unit, onCycleLoopMode: () -> Unit, modifier: Modifier = Modifier ) { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .widthIn(max = 480.dp) - .align(Alignment.TopCenter), - horizontalAlignment = Alignment.CenterHorizontally + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIconButton( + onClick = onToggleShuffle, + style = MillenniumIconButtonStyle.Quiet ) { - Spacer(modifier = Modifier.height(32.dp)) - - // Album art - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(16.dp)) - .background(AppTheme.colors.surfaceVariant) - ) { - if (playerState.currentTrackCoverUrl != null) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = stringResource(Res.string.devices_remote_album_cover), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = null, - tint = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.size(48.dp) - ) + MillenniumIcon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = stringResource( + if (state.isShuffling) { + Res.string.player_disable_shuffle_content_description + } else { + Res.string.player_enable_shuffle_content_description } + ), + tint = if (state.isShuffling) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant + ) + } + MillenniumIconButton( + onClick = onSkipPrevious, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = stringResource(Res.string.player_previous_content_description) + ) + } + MillenniumIconButton( + onClick = onTogglePlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = if (state.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource( + if (state.isPlaying) { + Res.string.player_pause_content_description + } else { + Res.string.player_play_content_description + } + ), + modifier = Modifier.size(30.dp), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = onSkipNext, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxNext, + contentDescription = stringResource(Res.string.player_next_content_description) + ) + } + MillenniumIconButton( + onClick = onCycleLoopMode, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = when (state.loopMode) { + RemoteLoopMode.NONE -> Iconsax.IconsaxRepeateMusic + RemoteLoopMode.ONE -> Iconsax.IconsaxRepeateOne + RemoteLoopMode.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource( + when (state.loopMode) { + RemoteLoopMode.NONE -> Res.string.player_loop_mode_none + RemoteLoopMode.ONE -> Res.string.player_loop_mode_one + RemoteLoopMode.ALL -> Res.string.player_loop_mode_all + } + ), + tint = if (state.loopMode == RemoteLoopMode.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Track info - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = playerState.currentTrackTitle ?: stringResource(Res.string.devices_remote_unknown_track), - style = AppTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = playerState.currentTrackArtists ?: stringResource(Res.string.devices_remote_unknown_artist), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (playerState.currentTrackAlbum != null) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = playerState.currentTrackAlbum!!, - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Seek bar - Column( - modifier = Modifier.fillMaxWidth() - ) { - MillenniumSlider( - value = playerState.positionMs.toFloat(), - onValueChange = { onSeek(it.toLong()) }, - label = stringResource(Res.string.devices_remote_seek), - valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), - modifier = Modifier.fillMaxWidth() - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = formatDuration(playerState.positionMs), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant - ) - Text( - text = formatDuration(playerState.durationMs), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Playback controls - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - // Shuffle - MillenniumIconButton( - onClick = onToggleShuffle, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = stringResource(Res.string.devices_remote_shuffle), - tint = if (playerState.shuffleEnabled) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - } - ) - } - - // Skip previous - MillenniumIconButton( - onClick = onSkipPrevious, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxPrevious, - contentDescription = stringResource(Res.string.devices_remote_previous), - modifier = Modifier.size(32.dp) - ) - } - - // Play/Pause - MillenniumIconButton( - onClick = onTogglePlayPause, - style = MillenniumIconButtonStyle.Primary - ) { - MillenniumIcon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) { - stringResource(Res.string.player_pause_content_description) - } else { - stringResource(Res.string.player_play_content_description) - }, - modifier = Modifier.size(40.dp), - tint = LocalTextStyle.current.color - ) - } - - // Skip next - MillenniumIconButton( - onClick = onSkipNext, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxNext, - contentDescription = stringResource(Res.string.devices_remote_next), - modifier = Modifier.size(32.dp) - ) - } - - // Loop mode - MillenniumIconButton( - onClick = onCycleLoopMode, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = stringResource(Res.string.devices_remote_loop_mode), - tint = if (playerState.loopMode != "none") { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - } - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Volume control - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxVolumeHigh, - contentDescription = stringResource(Res.string.devices_remote_volume), - tint = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) - - MillenniumSlider( - value = playerState.volume, - onValueChange = onSetVolume, - label = stringResource(Res.string.devices_remote_volume), - valueRange = 0f..1f, - modifier = Modifier.weight(1f) - ) - } - - Spacer(modifier = Modifier.height(32.dp)) + ) } } } @Composable -private fun RemoteQueueSection( - queueState: RemoteQueueState, +private fun VolumeControl( + state: RemoteControlUiState, + onVolumeChange: (Float) -> Unit, + onVolumeChangeFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MillenniumIcon( + imageVector = when (state.volumeLevel) { + RemoteVolumeLevel.MUTED -> Iconsax.IconsaxVolumeCross + RemoteVolumeLevel.LOW -> Iconsax.IconsaxVolumeLow + RemoteVolumeLevel.HIGH -> Iconsax.IconsaxVolumeHigh + }, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + MillenniumSlider( + value = state.volume, + onValueChange = onVolumeChange, + onValueChangeFinished = onVolumeChangeFinished, + label = stringResource(Res.string.devices_remote_volume), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ConnectionStatus( + title: String, + message: String, + leading: @Composable () -> Unit, + actionLabel: String? = null, + onAction: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + leading() + Text( + text = title, + style = AppTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = message, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (actionLabel != null) { + MillenniumButton( + onClick = onAction, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(actionLabel) + } + } + } + } +} + +@Composable +private fun StatusIcon(icon: ImageVector, isError: Boolean) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background( + if (isError) AppTheme.colors.errorContainer else AppTheme.colors.surfaceContainerHighest + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + tint = if (isError) AppTheme.colors.onErrorContainer else AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(32.dp) + ) + } +} + +@Composable +private fun RemoteQueueList( + state: RemoteControlUiState, onPlayQueueItem: (Int) -> Unit, onRemoveQueueItem: (String) -> Unit ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( text = stringResource(Res.string.queue_title), - style = AppTheme.typography.titleLarge + style = AppTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 4.dp) ) - if (queueState.entries.isEmpty()) { - Text( - text = stringResource(Res.string.queue_no_queue_entries), - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant - ) + if (state.queue.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(Res.string.queue_no_queue_entries), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } } else { + // Open with the current track in view; after that the user owns the scroll position. + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = state.currentQueueIndex.coerceAtLeast(0) + ) LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = listState, contentPadding = PaddingValues(bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - itemsIndexed( - items = queueState.entries, - key = { index, entry -> "${entry.mediaUrl}@$index" }) { index, entry -> + items(items = state.queue, key = { it.key }) { item -> RemoteQueueItemRow( - entry = entry, - index = index, - isCurrent = index == queueState.currentIndex, - onPlayClick = { onPlayQueueItem(index) }, - onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }) + item = item, + onPlayClick = { onPlayQueueItem(item.index) }, + onRemoveClick = { onRemoveQueueItem(item.mediaUrl) } + ) } } } @@ -538,9 +743,7 @@ private fun RemoteQueueSection( @Composable private fun RemoteQueueItemRow( - entry: RemoteQueueEntry, - index: Int, - isCurrent: Boolean, + item: RemoteQueueItemUi, onPlayClick: () -> Unit, onRemoveClick: () -> Unit ) { @@ -548,26 +751,25 @@ private fun RemoteQueueItemRow( MillenniumListTile( onClick = onPlayClick, - selected = isCurrent, - modifier = Modifier, + selected = item.isCurrent, leading = { Box( modifier = Modifier - .size(48.dp) + .size(40.dp) .clip(AppTheme.shapes.small) .background(AppTheme.colors.surfaceVariant), contentAlignment = Alignment.Center ) { - if (entry.coverUrl != null) { + if (item.coverUrl != null) { AsyncImage( - model = entry.coverUrl, + model = item.coverUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) } else { Text( - text = "${index + 1}", + text = "${item.index + 1}", style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant ) @@ -576,11 +778,11 @@ private fun RemoteQueueItemRow( }, title = { Text( - text = entry.title, + text = item.title, style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (isCurrent) { + color = if (item.isCurrent) { AppTheme.colors.onSecondaryContainer } else { AppTheme.colors.onSurface @@ -589,7 +791,7 @@ private fun RemoteQueueItemRow( }, subtitle = { Text( - text = entry.artists, + text = item.artists, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, @@ -598,7 +800,7 @@ private fun RemoteQueueItemRow( }, trailing = { Text( - text = formatDuration(entry.durationMs), + text = item.durationLabel, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant ) @@ -613,8 +815,7 @@ private fun RemoteQueueItemRow( ) { MillenniumIcon( Iconsax.Iconsax3DotsMore, - contentDescription = stringResource(Res.string.devices_remote_more_options), - modifier = Modifier.size(18.dp) + contentDescription = stringResource(Res.string.devices_remote_more_options) ) } }) { @@ -630,10 +831,3 @@ private fun RemoteQueueItemRow( } ) } - -private fun formatDuration(ms: Long): String { - val duration = ms.milliseconds - val minutes = duration.inWholeMinutes - val seconds = duration.inWholeSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt index 9b4c0896..0c5d78c1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt @@ -19,164 +19,244 @@ package org.spotube.spotube.modules.devices import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger import org.spotube.spotube.core.remote.ConnectionState import org.spotube.spotube.core.remote.RemoteControlClient import org.spotube.spotube.core.remote.RemoteControlCommand import org.spotube.spotube.core.remote.RemoteControlEvent -import org.spotube.spotube.core.remote.RemoteQueueEntry +import org.spotube.spotube.modules.shell.formatPlayerTime +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.time.Duration.Companion.milliseconds -data class RemotePlayerState( +enum class RemoteLoopMode(val wireValue: String) { + NONE("none"), + ONE("one"), + ALL("all"); + + fun next(): RemoteLoopMode = when (this) { + NONE -> ONE + ONE -> ALL + ALL -> NONE + } + + companion object { + fun fromWire(value: String): RemoteLoopMode = entries.firstOrNull { it.wireValue == value } ?: NONE + } +} + +enum class RemoteVolumeLevel { MUTED, LOW, HIGH } + +data class RemoteQueueItemUi( + val key: String, + val index: Int, + val mediaUrl: String, + val title: String, + val artists: String, + val coverUrl: String?, + val durationLabel: String, + val isCurrent: Boolean) + +data class RemoteControlUiState( + val connection: ConnectionState = ConnectionState.Disconnected, + val deviceName: String = "", + val hasTrack: Boolean = false, + val title: String? = null, + val artists: String? = null, + val album: String? = null, + val coverUrl: String? = null, val isPlaying: Boolean = false, - val positionMs: Long = 0, - val durationMs: Long = 0, - val volume: Float = 1.0f, - val shuffleEnabled: Boolean = false, - val loopMode: String = "none", - val currentTrackId: String? = null, - val currentTrackTitle: String? = null, - val currentTrackArtists: String? = null, - val currentTrackAlbum: String? = null, - val currentTrackCoverUrl: String? = null) + val isShuffling: Boolean = false, + val loopMode: RemoteLoopMode = RemoteLoopMode.NONE, + val canSeek: Boolean = false, + val seekProgress: Float = 0f, + val positionLabel: String = formatPlayerTime(0.milliseconds), + val durationLabel: String = formatPlayerTime(0.milliseconds), + val volume: Float = 1f, + val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH, + val queue: List = emptyList(), + val currentQueueIndex: Int = -1, + val isQueueVisible: Boolean = false) -data class RemoteQueueState( - val entries: List = emptyList(), - val currentIndex: Int = -1) +private data class LocalOverrides( + val seekProgress: Float?, + val volume: Float?, + val isQueueVisible: Boolean) class RemoteControlViewModel : ViewModel(), KoinComponent { - private val logger = Logger.withTag("RemoteControlViewModel") private val remoteControlClient: RemoteControlClient by inject() - private val _playerState = MutableStateFlow(RemotePlayerState()) - val playerState: StateFlow = _playerState.asStateFlow() + // While the user drags a slider these hold the local value, so the host's + // periodic state pushes don't yank the thumb back mid-gesture. + private val seekOverride = MutableStateFlow(null) + private val volumeOverride = MutableStateFlow(null) + private val isQueueVisible = MutableStateFlow(false) - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - val connectionState: StateFlow = _connectionState.asStateFlow() + private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible -> + LocalOverrides(seek, volume, queueVisible) + } - private val _queueState = MutableStateFlow(RemoteQueueState()) - val queueState: StateFlow = _queueState.asStateFlow() - - private val _isQueueVisible = MutableStateFlow(false) - val isQueueVisible: StateFlow = _isQueueVisible.asStateFlow() + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + remoteControlClient.latestPlayerState, + remoteControlClient.latestQueue, + overrides + ) { connection, player, queue, local -> + buildUiState(connection, player, queue, local) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState()) init { + // Stream volume changes while dragging, throttled so a fast drag doesn't + // flood the socket with one command per frame. viewModelScope.launch { - remoteControlClient.connectionState.collect { state -> - _connectionState.value = state - } - } - - viewModelScope.launch { - remoteControlClient.latestPlayerState.collect { event -> - if (event != null) { - handlePlayerState(event) - } - } - } - - viewModelScope.launch { - remoteControlClient.latestQueue.collect { event -> - if (event != null) { - handleQueueUpdated(event) - } + volumeOverride.filterNotNull().collect { volume -> + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + delay(VOLUME_THROTTLE_MS) } } } - private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { - _playerState.update { - it.copy( - isPlaying = event.isPlaying, - positionMs = event.positionMs, - durationMs = event.durationMs, - volume = event.volume, - shuffleEnabled = event.shuffleEnabled, - loopMode = event.loopMode, - currentTrackId = event.currentTrackId, - currentTrackTitle = event.currentTrackTitle, - currentTrackArtists = event.currentTrackArtists, - currentTrackAlbum = event.currentTrackAlbum, - currentTrackCoverUrl = event.currentTrackCoverUrl) + private fun buildUiState( + connection: ConnectionState, + player: RemoteControlEvent.PlayerState?, + queue: RemoteControlEvent.QueueUpdated?, + local: LocalOverrides + ): RemoteControlUiState { + val durationMs = player?.durationMs ?: 0L + val canSeek = durationMs > 0L + val liveProgress = if (canSeek) { + ((player?.positionMs ?: 0L).toFloat() / durationMs).coerceIn(0f, 1f) + } else { + 0f } + val seekProgress = local.seekProgress ?: liveProgress + val currentIndex = queue?.currentIndex ?: -1 + val volume = local.volume ?: player?.volume ?: 1f + + return RemoteControlUiState( + connection = connection, + deviceName = (connection as? ConnectionState.Connected)?.deviceName.orEmpty(), + hasTrack = player?.currentTrackId != null || player?.currentTrackTitle != null, + title = player?.currentTrackTitle, + artists = player?.currentTrackArtists, + album = player?.currentTrackAlbum, + coverUrl = player?.currentTrackCoverUrl, + isPlaying = player?.isPlaying ?: false, + isShuffling = player?.shuffleEnabled ?: false, + loopMode = RemoteLoopMode.fromWire(player?.loopMode ?: RemoteLoopMode.NONE.wireValue), + canSeek = canSeek, + seekProgress = seekProgress, + positionLabel = formatPlayerTime((durationMs * seekProgress).toLong().milliseconds), + durationLabel = formatPlayerTime(durationMs.milliseconds), + volume = volume, + volumeLevel = when { + volume <= 0f -> RemoteVolumeLevel.MUTED + volume < 0.5f -> RemoteVolumeLevel.LOW + else -> RemoteVolumeLevel.HIGH + }, + queue = queue?.entries.orEmpty().mapIndexed { index, entry -> + RemoteQueueItemUi( + key = "${entry.mediaUrl}@$index", + index = index, + mediaUrl = entry.mediaUrl, + title = entry.title, + artists = entry.artists, + coverUrl = entry.coverUrl, + durationLabel = formatPlayerTime(entry.durationMs.milliseconds), + isCurrent = index == currentIndex) + }, + currentQueueIndex = currentIndex, + isQueueVisible = local.isQueueVisible) } - private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { - _queueState.value = RemoteQueueState( - entries = event.entries, - currentIndex = event.currentIndex) - } - - fun togglePlayPause() { + private fun send(command: RemoteControlCommand) { viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) + remoteControlClient.sendCommand(command) } } - fun skipNext() { + /** + * Keeps [override] at [value] until the host reports back (or a timeout), so + * the slider doesn't flash the stale value in between. Skips the reset if the + * user already started a new drag. + */ + private suspend fun releaseOverrideAfterEcho(override: MutableStateFlow, value: Float) { + withTimeoutOrNull(OVERRIDE_RELEASE_TIMEOUT_MS) { + remoteControlClient.latestPlayerState.drop(1).first() + } + override.compareAndSet(value, null) + } + + fun togglePlayPause() = send(RemoteControlCommand.TogglePlayPause) + + fun skipNext() = send(RemoteControlCommand.SkipNext) + + fun skipPrevious() = send(RemoteControlCommand.SkipPrevious) + + fun onSeekChange(progress: Float) { + seekOverride.value = progress.coerceIn(0f, 1f) + } + + fun onSeekFinished() { + val progress = seekOverride.value ?: return + val durationMs = remoteControlClient.latestPlayerState.value?.durationMs ?: 0L + if (durationMs <= 0L) { + seekOverride.value = null + return + } viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.SkipNext) + remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong())) + releaseOverrideAfterEcho(seekOverride, progress) } } - fun skipPrevious() { - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious) - } + fun onVolumeChange(volume: Float) { + volumeOverride.value = volume.coerceIn(0f, 1f) } - fun seek(positionMs: Long) { - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs)) - } - } - - fun setVolume(volume: Float) { + fun onVolumeChangeFinished() { + val volume = volumeOverride.value ?: return viewModelScope.launch { remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + releaseOverrideAfterEcho(volumeOverride, volume) } } fun toggleShuffle() { - viewModelScope.launch { - val newState = !_playerState.value.shuffleEnabled - remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState)) - } + val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false + send(RemoteControlCommand.SetShuffle(!shuffling)) } fun cycleLoopMode() { - viewModelScope.launch { - val currentMode = _playerState.value.loopMode - val newMode = when (currentMode) { - "none" -> "one" - "one" -> "all" - else -> "none" - } - remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode)) - } + val current = RemoteLoopMode.fromWire( + remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue) + send(RemoteControlCommand.SetLoopMode(current.next().wireValue)) } fun playQueueItem(index: Int) { if (index < 0) return - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index)) - } + send(RemoteControlCommand.PlayIndex(index)) } - fun removeQueueItem(mediaUrl: String) { - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl)) - } - } + fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl)) fun toggleQueueVisibility() { - _isQueueVisible.update { !it } + isQueueVisible.update { !it } + } + + fun dismissQueue() { + isQueueVisible.value = false } fun disconnect() { @@ -184,4 +264,9 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { remoteControlClient.disconnect() } } + + private companion object { + const val VOLUME_THROTTLE_MS = 100L + const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L + } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index 9a50178f..d6da4e16 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -56,7 +56,6 @@ data class UserSettings( val cacheFolder: String? = null, val cacheSizeLimitMB: Long = -1L, val enableEndlessPlayback: Boolean = true, - val enableConnect: Boolean = false, val playbackProxyServerPort: Int = 14769, // Remote Control (LAN) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt index 47d0e51f..7688d0b6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt @@ -122,21 +122,6 @@ internal fun LazyListScope.playbackSection( } ) } - add { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_connect_title), - subtitle = stringResource(Res.string.settings_enable_connect_subtitle), - icon = { - SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title)) - }, - checked = settings.enableConnect, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableConnect = enabled) - } - } - ) - } add { SwitchSettingCard( title = stringResource(Res.string.settings_allow_remote_control_title), diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt new file mode 100644 index 00000000..2f73a824 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +// The OS's own service-discovery stack advertises on the right network. +internal actual fun preferredAdvertiseAddress(): String? = null diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt new file mode 100644 index 00000000..94376d20 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt @@ -0,0 +1,53 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +import java.net.DatagramSocket +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface + +// Interface name prefixes for container/VM/VPN links other LAN devices can't reach. +private val VirtualInterfacePrefixes = listOf( + "docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman", + "lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham") + +internal actual fun preferredAdvertiseAddress(): String? = + (routedLanAddress() ?: firstPhysicalLanAddress())?.hostAddress + +/** + * The source address the OS would use to reach the outside world, i.e. the interface that + * owns the default route. A connected UDP socket sends nothing; it only resolves the route. + */ +private fun routedLanAddress(): InetAddress? = runCatching { + DatagramSocket().use { socket -> + socket.connect(InetAddress.getByName("192.0.2.1"), 9) // TEST-NET-1, never routed anywhere + socket.localAddress + } +}.getOrNull()?.takeIf { it is Inet4Address && it.isSiteLocalAddress } + +/** Fallback for networks without a default route (e.g. an offline LAN). */ +private fun firstPhysicalLanAddress(): InetAddress? = runCatching { + NetworkInterface.getNetworkInterfaces().toList() + .filter { iface -> + iface.isUp && !iface.isLoopback && !iface.isVirtual && !iface.isPointToPoint && + VirtualInterfacePrefixes.none { iface.name.startsWith(it) } + } + .flatMap { it.inetAddresses.toList() } + .firstOrNull { it is Inet4Address && it.isSiteLocalAddress } +}.getOrNull() From d4961bb5edf066de3ba00b169ab1b28e0d898aba Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 15:54:23 +0600 Subject: [PATCH 089/132] feat: download diagnostics, live connect status, remote settings on devices screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads - Show the real download folder: "Current folder: (default)" in settings instead of the misleading "default music folder", and a "Saved to " line on the Downloads screen - Register finished downloads with Android's MediaStore (MediaScanner) so music players and the Files app see them immediately - Add tagged logs across the download path to diagnose format/quality selection: raw NewPipe streams ([Streams]), every selection candidate with its rank key and fallback warnings ([Resolve]), and per-download enqueue prefs, resolved stream, HEAD/GET status + content type, segment responses and written vs expected bytes ([Download]) Connect status - Mobile Home keeps its Devices / Group Jam / Support icons (Jam now uses the sound icon) and shows a slim banner only while remote-controlling a device or in a Jam ("Playing on krtx", "Hosting · 3 listeners") - Sidebar Devices / Group Jam rows get a live dot and status line - New ConnectStatusViewModel feeds both Remote control settings - Extract "Allow remote control" and "Device name" into shared remoteControlSettingCards, used by Playback settings and a new "This device" section on the Devices screen --- .../core/newpipe/NewPipeService.androidJvm.kt | 34 ++++- .../modules/downloads/MediaLibrary.android.kt | 27 ++++ .../composeResources/values-ar/strings.xml | 1 - .../composeResources/values-bn/strings.xml | 1 - .../composeResources/values-de/strings.xml | 1 - .../composeResources/values-es/strings.xml | 1 - .../composeResources/values-fa/strings.xml | 1 - .../composeResources/values-fr/strings.xml | 1 - .../composeResources/values-hi/strings.xml | 1 - .../composeResources/values-id/strings.xml | 1 - .../composeResources/values-it/strings.xml | 1 - .../composeResources/values-ja/strings.xml | 1 - .../composeResources/values-ko/strings.xml | 1 - .../composeResources/values-nl/strings.xml | 1 - .../composeResources/values-pl/strings.xml | 1 - .../composeResources/values-pt/strings.xml | 1 - .../composeResources/values-ru/strings.xml | 1 - .../composeResources/values-th/strings.xml | 1 - .../composeResources/values-tr/strings.xml | 1 - .../composeResources/values-uk/strings.xml | 1 - .../composeResources/values-vi/strings.xml | 1 - .../values-zh-rTW/strings.xml | 1 - .../composeResources/values-zh/strings.xml | 1 - .../composeResources/values/strings.xml | 12 +- .../org/spotube/spotube/core/di/Modules.kt | 2 + .../core/server/StreamingUrlRepository.kt | 62 ++++++++- .../spotube/modules/devices/DevicesScreen.kt | 20 ++- .../modules/downloads/DownloadManager.kt | 101 ++++++++++++-- .../modules/downloads/DownloadsScreen.kt | 17 ++- .../modules/downloads/DownloadsViewModel.kt | 13 +- .../spotube/modules/downloads/MediaLibrary.kt | 21 +++ .../spotube/modules/home/HomeScreen.kt | 14 +- .../modules/settings/SettingsViewModel.kt | 7 +- .../settings/sections/DownloadsSection.kt | 6 +- .../settings/sections/PlaybackSection.kt | 48 +------ .../sections/RemoteControlSettings.kt | 92 ++++++++++++ .../spotube/modules/shell/AppSidebar.kt | 74 +++++++++- .../modules/shell/ConnectStatusBanner.kt | 131 ++++++++++++++++++ .../modules/shell/ConnectStatusViewModel.kt | 57 ++++++++ .../modules/downloads/MediaLibrary.ios.kt | 21 +++ .../modules/downloads/MediaLibrary.jvm.kt | 21 +++ 41 files changed, 695 insertions(+), 106 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index 39cf7930..7e4311dc 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -19,6 +19,8 @@ package org.spotube.spotube.core.newpipe import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.server.describeForLog import io.ktor.http.Url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -30,6 +32,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 actual class NewPipeService { + private val logger = Logger.withTag("NewPipeService") + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( @@ -66,6 +70,22 @@ actual class NewPipeService { val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id") extractor.fetchPage() + // Raw extractor output, before any mapping/filtering, so stream-selection issues can be + // traced back to what YouTube actually offered for this video. + logger.i { + "[Streams $id] '${extractor.name}' raw audioStreams=${extractor.audioStreams.size}:\n" + + extractor.audioStreams.joinToString("\n") { stream -> + " itag=${stream.itag} format=${stream.format?.name} mime=${stream.format?.mimeType} " + + "suffix=${stream.format?.suffix} codec=${stream.codec} bitrate=${stream.bitrate} " + + "avgBitrate=${stream.averageBitrate} delivery=${stream.deliveryMethod} isUrl=${stream.isUrl} " + + "trackType=${stream.audioTrackType}" + } + } + logger.i { + "[Streams $id] dashMpdUrl=${extractor.dashMpdUrl.isNotBlank()} hlsUrl=${extractor.hlsUrl.isNotBlank()} " + + "raw videoStreams=${extractor.videoStreams.size} videoOnlyStreams=${extractor.videoOnlyStreams.size}" + } + val audioStreams = extractor.audioStreams .mapNotNull { stream -> AudioStream.Lossy( @@ -103,6 +123,10 @@ actual class NewPipeService { bitrate = stream.bitrate, container = stream.format!!.suffix) } + logger.i { + "[Streams $id] mapped audio candidates (${audioStreams.size}):\n" + + audioStreams.joinToString("\n") { " ${it.describeForLog()}" } + } VideoInfo( id = id, title = extractor.name, @@ -112,10 +136,18 @@ actual class NewPipeService { thumbnailUrl = extractor.thumbnails.first().url, // fallback to video streams if no audio streams are available audioStreams = audioStreams.ifEmpty { + logger.w { + "[Streams $id] no audio streams; FALLING BACK TO ${videoStreams.size} VIDEO streams " + + "(muxed video+audio, downloads will be video files):\n" + + extractor.videoStreams.joinToString("\n") { + " itag=${it.itag} format=${it.format?.name} res=${it.resolution} " + + "codec=${it.codec} videoOnly=${it.isVideoOnly} isUrl=${it.isUrl}" + } + } videoStreams }, videoStreams = videoStreams ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt new file mode 100644 index 00000000..672d70f4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt @@ -0,0 +1,27 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.downloads + +import android.content.Context +import android.media.MediaScannerConnection +import org.koin.core.context.GlobalContext + +internal actual fun addToMediaLibrary(path: String) { + val context = GlobalContext.get().get() + MediaScannerConnection.scanFile(context, arrayOf(path), null, null) +} diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index af2a5da6..febc7e6a 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -179,7 +179,6 @@ تخصيص مجلد التنزيل المجلد الحالي: %1$s - المجلد الحالي: مجلد الموسيقى الافتراضي اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي. C:\Music\Spotube مجلدات الوسائط المحلية diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index cf54df53..ca7c78cf 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -171,7 +171,6 @@ ডাউনলোড ফোল্ডার পরিবর্তন বর্তমান ফোল্ডার: %1$s - বর্তমান ফোল্ডার: ডিফল্ট মিউজিক ফোল্ডার ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন। C:\Music\Spotube লোকাল মিডিয়া ফোল্ডার diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 22f864c8..ec3d8da3 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -171,7 +171,6 @@ Eigener Download-Ordner Aktueller Ordner: %1$s - Aktueller Ordner: Standard-Musikordner Leer lassen, um den Standard-Musikordner zu verwenden. C:\Music\Spotube Lokale Medienordner diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 10092442..4fec22e5 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -173,7 +173,6 @@ Carpeta de descargas personalizada Carpeta actual: %1$s - Carpeta actual: carpeta de música predeterminada Déjalo vacío para usar la carpeta de música predeterminada. C:\Music\Spotube Carpetas de contenido local diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 84c45926..679313d5 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -171,7 +171,6 @@ پوشهٔ دانلود سفارشی پوشهٔ فعلی: %1$s - پوشهٔ فعلی: پوشهٔ پیش‌فرض موسیقی برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید. C:\Music\Spotube پوشه‌های رسانهٔ محلی diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index b5be84d8..644cb9f9 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -173,7 +173,6 @@ Dossier de téléchargement personnalisé Dossier actuel : %1$s - Dossier actuel : dossier Musique par défaut Laissez vide pour utiliser le dossier Musique par défaut. C:\Music\Spotube Dossiers de médias locaux diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 15344dcd..4deeb649 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -170,7 +170,6 @@ डाउनलोड फ़ोल्डर बदलें मौजूदा फ़ोल्डर: %1$s - मौजूदा फ़ोल्डर: डिफ़ॉल्ट म्यूज़िक फ़ोल्डर डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें। C:\Music\Spotube लोकल मीडिया फ़ोल्डर diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index f189a383..27c80b88 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -169,7 +169,6 @@ Ganti folder unduhan Folder saat ini: %1$s - Folder saat ini: folder musik bawaan Biarkan kosong untuk memakai folder musik bawaan. C:\Music\Spotube Folder media lokal diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 67ce22bd..36196910 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -173,7 +173,6 @@ Cartella di download personalizzata Cartella attuale: %1$s - Cartella attuale: cartella Musica predefinita Lascia vuoto per usare la cartella Musica predefinita. C:\Music\Spotube Cartelle multimediali locali diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index c01033a0..c6b4195d 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -169,7 +169,6 @@ ダウンロードフォルダの変更 現在のフォルダ: %1$s - 現在のフォルダ: デフォルトの音楽フォルダ 空欄にするとデフォルトの音楽フォルダを使用します。 C:\Music\Spotube ローカルメディアフォルダ diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 1db8b5e5..cad8b072 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -169,7 +169,6 @@ 다운로드 폴더 지정 현재 폴더: %1$s - 현재 폴더: 기본 음악 폴더 기본 음악 폴더를 사용하려면 비워 두세요. C:\Music\Spotube 로컬 미디어 폴더 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index b6ab0848..3206d0d6 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -171,7 +171,6 @@ Andere downloadmap Huidige map: %1$s - Huidige map: standaard muziekmap Laat dit leeg om de standaard muziekmap te gebruiken. C:\Music\Spotube Lokale mediamappen diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 56dda439..fa6bcfb6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -175,7 +175,6 @@ Własny folder pobierania Obecny folder: %1$s - Obecny folder: domyślny folder muzyki Pozostaw puste, aby używać domyślnego folderu muzyki. C:\Music\Spotube Foldery lokalnych multimediów diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 95dcb260..f7c11447 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -173,7 +173,6 @@ Pasta de download personalizada Pasta atual: %1$s - Pasta atual: pasta de músicas padrão Deixe em branco para usar a pasta de músicas padrão. C:\Music\Spotube Pastas de mídia local diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index 2ce7ca5a..28b5450e 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -175,7 +175,6 @@ Своя папка для загрузок Текущая папка: %1$s - Текущая папка: стандартная папка «Музыка» Оставьте пустым, чтобы использовать стандартную папку «Музыка». C:\Music\Spotube Папки с локальной музыкой diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 246fb93e..2b14b08d 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -169,7 +169,6 @@ กำหนดโฟลเดอร์ดาวน์โหลดเอง โฟลเดอร์ปัจจุบัน: %1$s - โฟลเดอร์ปัจจุบัน: โฟลเดอร์เพลงเริ่มต้น เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น C:\Music\Spotube โฟลเดอร์สื่อในเครื่อง diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 9024bbca..123d18cd 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -171,7 +171,6 @@ Özel indirme klasörü Geçerli klasör: %1$s - Geçerli klasör: varsayılan müzik klasörü Varsayılan müzik klasörünü kullanmak için boş bırak. C:\Music\Spotube Yerel medya klasörleri diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index cb9bf14f..0fbd6d0e 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -175,7 +175,6 @@ Власна папка для завантажень Поточна папка: %1$s - Поточна папка: стандартна папка «Музика» Залиште порожнім, щоб використовувати стандартну папку «Музика». C:\Music\Spotube Папки з локальною музикою diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 6a6b75fb..241c50f4 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -169,7 +169,6 @@ Thư mục tải xuống tùy chỉnh Thư mục hiện tại: %1$s - Thư mục hiện tại: thư mục nhạc mặc định Để trống để dùng thư mục nhạc mặc định. C:\Music\Spotube Thư mục nhạc trên máy diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index 7aaee59d..e10e720e 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -169,7 +169,6 @@ 自訂下載資料夾 目前資料夾:%1$s - 目前資料夾:預設音樂資料夾 留空即使用預設音樂資料夾。 C:\Music\Spotube 本機媒體資料夾 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 1caaa3c2..b247fac7 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -169,7 +169,6 @@ 自定义下载文件夹 当前文件夹:%1$s - 当前文件夹:默认音乐文件夹 留空则使用默认音乐文件夹。 C:\Music\Spotube 本地媒体文件夹 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f7acd6a9..9cfcb895 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -76,6 +76,7 @@ %1$d active • %2$d completed + Saved to %1$s Clear completed No downloads yet Downloading %1$d% @@ -173,7 +174,7 @@ Download folder override Current folder: %1$s - Current folder: default music folder + Current folder: %1$s (default) Leave this empty to use the default music folder. C:\Music\Spotube Local media folders @@ -519,6 +520,7 @@ Resolving... Connected Available devices + This device Connected Open remote Search again @@ -925,6 +927,14 @@ On Device Connect Devices + + Hosting · %1$d listener + Hosting · %1$d listeners + + + In a Jam · %1$d listener + In a Jam · %1$d listeners + Hide Window diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index cd8e2742..5022fcd5 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -49,6 +49,7 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.blacklist.BlacklistViewModel import org.spotube.spotube.modules.devices.DevicesViewModel import org.spotube.spotube.modules.devices.RemoteControlViewModel +import org.spotube.spotube.modules.shell.ConnectStatusViewModel import org.spotube.spotube.modules.jam.JamViewModel import org.spotube.spotube.modules.downloads.DownloadManager import org.spotube.spotube.modules.downloads.DownloadsViewModel @@ -219,6 +220,7 @@ val sharedModules = module { viewModelOf(::BlacklistViewModel) viewModel { DevicesViewModel(get()) } viewModelOf(::RemoteControlViewModel) + viewModelOf(::ConnectStatusViewModel) viewModel { JamViewModel( jamRoomService = get(), diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 3375f2d2..3a20b697 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -102,10 +102,19 @@ class StreamingUrlRepository( logger.w { "No audio plugin selected while resolving stream for track $trackId" } return null } + // NOTE: always the *streaming* preferences, also when called by the downloader. + logger.i { + "[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " + + "prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " + + "(streamingMusicFormat/streamingMusicQuality)" + } if (!forceRefresh) { getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> - logger.v { "Using cached stream URL for track $trackId" } + logger.i { + "[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " + + "protocol=${cached.protocol} ${urlSummary(cached.url)}" + } return cached } } @@ -130,6 +139,7 @@ class StreamingUrlRepository( stream.streams, preferredFormat, preferredQuality) + logSelection(trackId, "matched source ${source.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -189,6 +199,7 @@ class StreamingUrlRepository( stream.streams, preferredFormat, preferredQuality) + logSelection(trackId, "track lookup -> ${stream.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -197,6 +208,38 @@ class StreamingUrlRepository( return null } + /** Logs every candidate with its ranking key so it's obvious *why* a stream won. */ + private fun logSelection( + trackId: String, + origin: String, + streams: List, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + selected: AudioStream?) { + logger.i { + "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + + "(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" + + streams.joinToString("\n") { stream -> + val marker = if (stream === selected) " -> " else " " + "$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" + } + } + when { + selected == null -> logger.w { "[Resolve $trackId] no stream selected" } + formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { + "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + + "got ${selected.codec}/${selected.container} (${selected.protocol})" + } + selected.qualityDistance(preferredQuality) != 0L -> logger.w { + "[Resolve $trackId] QUALITY FALLBACK: wanted $preferredQuality, got ${selected.describeForLog()}" + } + } + } + private fun AudioStream.toStreamInfo() = StreamInfo( url = normalizeManifestUrl(url, protocol), protocol = protocol, @@ -353,3 +396,20 @@ private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = w else -> 0 } + +internal fun AudioStream.describeForLog(): String = when (this) { + is AudioStream.Lossy -> "lossy codec=$codec container=$container bitrate=$bitrate protocol=$protocol" + is AudioStream.Lossless -> + "lossless codec=$codec container=$container sampleRate=$sampleRate channels=$channels protocol=$protocol" +} + +/** + * Host plus the query params that identify a YouTube stream (itag, mime, clen = byte size, dur), + * instead of the full signed URL. + */ +internal fun urlSummary(url: String): String = runCatching { + val parsed = io.ktor.http.Url(url) + val keys = listOf("itag", "mime", "clen", "dur") + val params = keys.mapNotNull { key -> parsed.parameters[key]?.let { "$key=$it" } } + "url=${parsed.host}${parsed.encodedPath.take(40)}" + if (params.isEmpty()) "" else " ${params.joinToString(" ")}" +}.getOrDefault("url=") diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt index aef1a96f..58067ff9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -58,8 +58,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.SettingsViewModel import org.spotube.spotube.modules.settings.components.SettingCardItem import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.remoteControlSettingCards import org.spotube.spotube.modules.settings.sections.settingsSectionCard import org.spotube.spotube.modules.settings.sections.settingsSectionHeader import org.spotube.spotube.modules.shell.LocalAppShellBottomInset @@ -83,6 +85,7 @@ import spotube.composeapp.generated.resources.devices_searching import spotube.composeapp.generated.resources.devices_searching_more import spotube.composeapp.generated.resources.devices_section_available import spotube.composeapp.generated.resources.devices_section_connected +import spotube.composeapp.generated.resources.devices_section_this_device import spotube.composeapp.generated.resources.devices_status_connected import spotube.composeapp.generated.resources.devices_title import spotube.composeapp.generated.resources.devices_unknown_device @@ -92,9 +95,11 @@ private val ContentMaxWidth = 720.dp @Composable fun DevicesScreen( - viewModel: DevicesViewModel = koinViewModel() + viewModel: DevicesViewModel = koinViewModel(), + settingsViewModel: SettingsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val settings by settingsViewModel.settingsState.collectAsStateWithLifecycle() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val shellBottomInset = LocalAppShellBottomInset.current @@ -209,6 +214,15 @@ fun DevicesScreen( ) } } + + // Same cards as Settings > Playback, so making this device controllable (or renaming + // it) doesn't require a detour through Settings. + settings?.let { current -> + settingsSectionHeader(Res.string.devices_section_this_device) + settingsSectionCard( + items = remoteControlSettingCards(current, settingsViewModel, requestLocalNetworkPermission) + ) + } } } } @@ -347,11 +361,11 @@ private fun LazyItemScope.EmptyState( isDiscovering: Boolean, onSearchAgain: () -> Unit ) { + // Fixed padding rather than filling the viewport, so the "This device" section below stays visible. Box( modifier = Modifier - .fillParentMaxHeight(0.8f) .fillParentMaxWidth() - .padding(24.dp), + .padding(horizontal = 24.dp, vertical = 48.dp), contentAlignment = Alignment.Center ) { Column( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index d2261217..d2f91aae 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -22,6 +22,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.server.StreamInfo import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.urlSummary +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout @@ -31,6 +33,7 @@ import io.ktor.client.request.headers import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders import io.ktor.http.contentLength +import io.ktor.http.isSuccess import io.ktor.utils.io.readRemaining import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -112,6 +115,11 @@ class DownloadManager( private val cachedDownloadFolder = MutableStateFlow(null) + /** Where downloads are saved right now: the user's override, else the platform default. */ + val downloadFolder: StateFlow = cachedDownloadFolder + .map { it?.takeIf(String::isNotBlank) ?: paths.getUserDownloadsDirPath() } + .stateIn(scope, SharingStarted.Eagerly, paths.getUserDownloadsDirPath()) + private val activeJobs = mutableMapOf() init { @@ -135,6 +143,13 @@ class DownloadManager( track = track) downloadsMap.update { it + (id to item) } + val settings = settingsRepository.userSettings.value + logger.i { + "[Download $id] enqueued '${track.title}' by $artistNames (track=${track.id}) " + + "downloadPrefs=${settings.downloadMusicFormat.codec}/${settings.downloadMusicFormat.container} " + + "${settings.downloadMusicQuality} streamingPrefs=${settings.streamingMusicFormat.codec}/" + + "${settings.streamingMusicFormat.container} ${settings.streamingMusicQuality} folder=${downloadFolder.value}" + } scope.launch { processQueue() } } @@ -210,6 +225,7 @@ class DownloadManager( } try { + logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" } val streamInfo = streamingUrlRepository.resolveStreamInfo(track) if (streamInfo == null) { logger.w { "Could not resolve stream URL for ${track.title}" } @@ -226,6 +242,7 @@ class DownloadManager( } val url = streamInfo.url + logResolvedStream(item, streamInfo) val filename = buildFilename(item, streamInfo) @@ -235,15 +252,28 @@ class DownloadManager( val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath() fileSystem.createDirectories(tempDir) - val (totalSize, supportsRanges) = probeUrl(url) + val (totalSize, supportsRanges) = probeUrl(item.id, url) if (totalSize > 0 && supportsRanges) { + logger.i { "[Download ${item.id}] multi-segment download ($SEGMENT_COUNT segments, $totalSize bytes) -> ${downloadDir / filename}" } executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize) } else { + logger.i { "[Download ${item.id}] single-connection download (size=$totalSize ranges=$supportsRanges) -> ${downloadDir / filename}" } executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir) } + val finalPath = downloadDir / filename.toPath() + val writtenBytes = fileSystem.metadataOrNull(finalPath)?.size + logger.i { "[Download ${item.id}] wrote $finalPath ($writtenBytes bytes, expected $totalSize)" } + if (totalSize > 0 && writtenBytes != null && writtenBytes != totalSize) { + logger.w { "[Download ${item.id}] SIZE MISMATCH: wrote $writtenBytes bytes, server reported $totalSize" } + } + applyMetadata(item, filename, downloadDir) + // Without this Android music players and the Files app's Audio view don't see the + // file until the next full media scan. + addToMediaLibrary((downloadDir / filename.toPath()).toString()) + logger.i { "[Download ${item.id}] completed '${item.title}'" } downloadsMap.update { map -> map[item.id]?.let { @@ -251,6 +281,7 @@ class DownloadManager( } ?: map } } catch (e: CancellationException) { + logger.i { "[Download ${item.id}] cancelled" } downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy(status = DownloadStatus.Cancelled)) @@ -258,7 +289,7 @@ class DownloadManager( } throw e } catch (e: Exception) { - logger.e(e) { "Download failed: ${item.title}" } + logger.e(e) { "[Download ${item.id}] FAILED '${item.title}': ${e.message}" } val failureMessage = e.message ?: getString(Res.string.common_unknown_error) downloadsMap.update { map -> map[item.id]?.let { @@ -322,6 +353,15 @@ class DownloadManager( append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") } } + logger.d { + "[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " + + "HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " + + "range=${response.headers[HttpHeaders.ContentRange]}" + } + // expectSuccess = false, so an error page would otherwise be saved as audio. + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; its body will be written as audio" } + } val channel = response.bodyAsChannel() fileSystem.sink(outputPath).buffer().use { sink -> @@ -344,6 +384,13 @@ class DownloadManager( val response = httpClient.get(url) val totalSize = response.contentLength() ?: 0L + logger.i { + "[Download ${item.id}] GET -> HTTP ${response.status.value} " + + "type=${response.headers[HttpHeaders.ContentType]} length=$totalSize" + } + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; its body will be written as audio" } + } val channel = response.bodyAsChannel() fileSystem.sink(tempFile).buffer().use { sink -> @@ -375,22 +422,55 @@ class DownloadManager( } } - private suspend fun probeUrl(url: String): Pair { + private suspend fun probeUrl(id: String, url: String): Pair { return try { val response = httpClient.head(url) val contentLength = response.contentLength() ?: 0L val acceptRanges = response.headers[HttpHeaders.AcceptRanges] val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true + val contentType = response.headers[HttpHeaders.ContentType] + logger.i { + "[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + + "length=$contentLength acceptRanges=$acceptRanges" + } + if (contentType?.startsWith("video/") == true) { + logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio" } + } Pair(contentLength, supportsRanges) } catch (e: Exception) { - logger.w(e) { "HEAD request failed, falling back to single connection" } + logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" } Pair(0L, false) } } + private fun logResolvedStream(item: DownloadItem, info: StreamInfo) { + val settings = settingsRepository.userSettings.value + val wanted = settings.downloadMusicFormat + logger.i { + "[Download ${item.id}] resolved stream codec=${info.codec} container=${info.container} " + + "protocol=${info.protocol} ${urlSummary(info.url)}" + } + if (info.protocol != StreamProtocol.PROGRESSIVE) { + logger.w { + "[Download ${item.id}] stream is a ${info.protocol} MANIFEST; a plain GET saves the manifest, not audio" + } + } + if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) { + logger.w { + "[Download ${item.id}] DOWNLOAD FORMAT NOT RESPECTED: download setting is ${wanted.codec}/${wanted.container} " + + "${settings.downloadMusicQuality}, got ${info.codec}/${info.container} " + + "(resolver used streaming prefs ${settings.streamingMusicFormat.codec}/${settings.streamingMusicFormat.container})" + } + } + } + private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) { val filePath = downloadDir / filename.toPath() - if (!fileSystem.exists(filePath)) return + if (!fileSystem.exists(filePath)) { + logger.w { "[Download ${item.id}] skipping metadata: $filePath does not exist" } + return + } + logger.d { "[Download ${item.id}] writing metadata to $filePath" } val track = item.track ?: return @@ -421,18 +501,11 @@ class DownloadManager( discNumber = track.discNumber, coverBytes = coverBytes) } catch (e: Exception) { - logger.w(e) { "Failed to write audio metadata for ${item.title}" } + logger.w(e) { "[Download ${item.id}] failed to write audio metadata for ${item.title}" } } } - private fun resolveDownloadDir(): Path { - val folder = cachedDownloadFolder.value - return if (!folder.isNullOrBlank()) { - folder.toPath() - } else { - paths.getUserDownloadsDirPath().toPath() - } - } + private fun resolveDownloadDir(): Path = downloadFolder.value.toPath() private fun cleanupTempFiles(id: String) { try { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt index 8d9c610c..8bcee0fa 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt @@ -100,11 +100,18 @@ internal fun DownloadsToolbarTitle() { @Composable internal fun DownloadsToolbarSummary(state: DownloadsUiState) { - if (state.items.isNotEmpty()) Text( - stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), - modifier = Modifier.padding(start = 28.dp), - style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant - ) + Column(Modifier.padding(start = 28.dp)) { + if (state.items.isNotEmpty()) Text( + stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + // Always visible (empty list included), so users know where their files end up. + if (state.folder.isNotBlank()) Text( + stringResource(Res.string.downloads_saved_to, state.folder), + style = AppTheme.typography.bodySmall, color = MillenniumTheme.colors.muted, + maxLines = 2, overflow = TextOverflow.Ellipsis + ) + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt index 68ab8d7d..8ee69171 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt @@ -23,7 +23,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine enum class DownloadRowAction { Cancel, Retry, Remove } @@ -43,10 +43,12 @@ data class DownloadsUiState( val items: List = emptyList(), val activeCount: Int = 0, val completedCount: Int = 0, - val canClearCompleted: Boolean = false + val canClearCompleted: Boolean = false, + /** Folder new downloads are saved to, shown so users can find their files. */ + val folder: String = "" ) -internal fun downloadsUiState(items: List): DownloadsUiState { +internal fun downloadsUiState(items: List, folder: String = ""): DownloadsUiState { val completedCount = items.count { it.status == DownloadStatus.Completed } return DownloadsUiState( items = items.map { item -> @@ -66,7 +68,8 @@ internal fun downloadsUiState(items: List): DownloadsUiState { }, activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, completedCount = completedCount, - canClearCompleted = completedCount > 0 + canClearCompleted = completedCount > 0, + folder = folder ) } @@ -79,7 +82,7 @@ class DownloadsViewModel( emptyList() ) - val uiState = downloadManager.downloadsFlow.map(::downloadsUiState) + val uiState = combine(downloadManager.downloadsFlow, downloadManager.downloadFolder, ::downloadsUiState) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) fun downloadTrack(track: MetadataTrack) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt new file mode 100644 index 00000000..75340bb2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.downloads + +/** Tells the OS media index about a finished download so other apps can see it right away. */ +internal expect fun addToMediaLibrary(path: String) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt index f3e32ebd..2a8a8944 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt @@ -75,13 +75,14 @@ import org.spotube.spotube.core.ui.component.dragScrollable import org.spotube.spotube.core.ui.misc.SkeletonTree import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.shell.ConnectStatusBanner +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone import org.spotube.spotube.getPlatform import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.support.LocalSupportPrompt import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxHeart2 import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen -import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res @@ -93,6 +94,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.jetbrains.compose.resources.Font import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular import spotube.composeapp.generated.resources.cookie_regular @Composable @@ -161,7 +163,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( - imageVector = Iconsax.IconsaxMirroringScreen, + imageVector = Iconsax.IconsaxMirrorScreenRegular, contentDescription = stringResource(Res.string.devices_title) ) } @@ -170,7 +172,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( - imageVector = Iconsax.User, + imageVector = Iconsax.IconsaxSoundTwotone, contentDescription = stringResource(Res.string.jam_screen_title) ) } @@ -186,6 +188,12 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { } } }) + if (isSmallScreen) { + ConnectStatusBanner( + onOpenRemote = { navigationCommands.navigateTo(Routes.RemoteControl) }, + onOpenJam = { navigationCommands.navigateTo(Routes.Jam) } + ) + } Box( modifier = Modifier .fillMaxSize() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt index 8fd6741a..c629111a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt @@ -23,16 +23,21 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.spotube.spotube.core.paths.Paths interface SettingsProvider { val settingsState: StateFlow } class SettingsViewModel( - private val repository: SettingsRepository + private val repository: SettingsRepository, + private val paths: Paths ) : ViewModel(), SettingsProvider { override val settingsState = repository.userSettings + /** Where downloads go when no override folder is set. */ + val defaultDownloadFolder: String = paths.getUserDownloadsDirPath() + fun updateSettings(transform: UserSettings.() -> UserSettings) { viewModelScope.launch { // Get current state, apply transform, and save diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt index 37b7bf71..fb5289d4 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt @@ -46,7 +46,7 @@ import io.github.vinceglb.filekit.path import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.settings_download_folder_current -import spotube.composeapp.generated.resources.settings_download_folder_default +import spotube.composeapp.generated.resources.settings_download_folder_default_path import spotube.composeapp.generated.resources.settings_download_folder_title import spotube.composeapp.generated.resources.settings_download_format_subtitle_current import spotube.composeapp.generated.resources.settings_download_format_title @@ -77,6 +77,7 @@ internal fun LazyListScope.downloadsSection( { DownloadFolderSettingCard( folder = settings.overloadedDownloadFolder, + defaultFolder = settingsViewModel.defaultDownloadFolder, onFolderSelected = { folder -> settingsViewModel.updateSettings { copy(overloadedDownloadFolder = folder) @@ -142,6 +143,7 @@ internal fun LazyListScope.downloadsSection( @Composable private fun DownloadFolderSettingCard( folder: String?, + defaultFolder: String, onFolderSelected: (String?) -> Unit ) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> @@ -157,7 +159,7 @@ private fun DownloadFolderSettingCard( title = stringResource(Res.string.settings_download_folder_title), subtitle = folder?.let { stringResource(Res.string.settings_download_folder_current, it) - } ?: stringResource(Res.string.settings_download_folder_default), + } ?: stringResource(Res.string.settings_download_folder_default_path, defaultFolder), icon = { SettingsItemIcon( Iconsax.IconsaxDirectboxReceive, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt index 7688d0b6..c60df146 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt @@ -41,9 +41,7 @@ import org.spotube.spotube.modules.settings.components.SwitchSettingCard import org.spotube.spotube.modules.settings.components.TextInputSettingCard import org.spotube.spotube.resources.iconsax.CustomServer import org.spotube.spotube.resources.iconsax.Iconsax -import org.spotube.spotube.resources.iconsax.IconsaxEdit import org.spotube.spotube.resources.iconsax.IconsaxForbidden -import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting @@ -122,51 +120,7 @@ internal fun LazyListScope.playbackSection( } ) } - add { - SwitchSettingCard( - title = stringResource(Res.string.settings_allow_remote_control_title), - subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), - icon = { - SettingsItemIcon( - Iconsax.IconsaxMirroringScreen, - stringResource(Res.string.settings_allow_remote_control_title) - ) - }, - checked = settings.allowRemoteControl, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(allowRemoteControl = enabled) - } - // Request the local network permission when enabling remote control - // so that DNS-SD registration can succeed on Android 16+ - if (enabled) { - requestLocalNetworkPermission() - } - } - ) - } - add { - TextInputSettingCard( - title = stringResource(Res.string.settings_remote_device_name_title), - subtitle = stringResource( - Res.string.settings_remote_device_name_subtitle, - settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) - }, - value = settings.remoteControlDeviceName, - dialogDescription = stringResource(Res.string.settings_remote_device_name_description), - placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), - normalize = { it.trim() }, - validate = { _ -> null }, - onValueSaved = { value -> - settingsViewModel.updateSettings { - copy(remoteControlDeviceName = value) - } - } - ) - } + addAll(remoteControlSettingCards(settings, settingsViewModel, requestLocalNetworkPermission)) add { val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_port_range = stringResource(Res.string.settings_error_port_range) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt new file mode 100644 index 00000000..5485f195 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt @@ -0,0 +1,92 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_allow_remote_control_subtitle +import spotube.composeapp.generated.resources.settings_allow_remote_control_title +import spotube.composeapp.generated.resources.settings_remote_device_name_default +import spotube.composeapp.generated.resources.settings_remote_device_name_description +import spotube.composeapp.generated.resources.settings_remote_device_name_placeholder +import spotube.composeapp.generated.resources.settings_remote_device_name_subtitle +import spotube.composeapp.generated.resources.settings_remote_device_name_title + +/** + * "Allow remote control" and "Device name" cards. Shared by the Playback settings section and the + * Devices screen so both stay identical. + */ +internal fun remoteControlSettingCards( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + requestLocalNetworkPermission: () -> Unit +): List<@Composable () -> Unit> = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } + } + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + } +) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt index a38af3bd..c28cff4b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt @@ -20,6 +20,20 @@ package org.spotube.spotube.modules.shell import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.LocalTextStyle +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.LocalMillenniumSidebar +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumControlState +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -80,8 +94,12 @@ fun AppSidebar( navigator: Navigator, navigationState: NavigationState, modifier: Modifier = Modifier, - libraryState: LibraryState = koinInject() + libraryState: LibraryState = koinInject(), + connectStatusViewModel: ConnectStatusViewModel = koinViewModel() ) { + val connectStatus by connectStatusViewModel.uiState.collectAsStateWithLifecycle() + val remoteStatus = connectStatus.remote?.let { remoteStatusLabel(it) } + val jamStatus = connectStatus.jam?.let { jamStatusLabel(it) } var expanded by rememberSaveable { mutableStateOf(true) } val groupExpansion = remember { mutableStateMapOf() } val currentLibraryTab by libraryState.currentTab.collectAsState() @@ -144,6 +162,11 @@ fun AppSidebar( SidebarNavItem( item = item, selected = selected, + status = when (item.route) { + Routes.Devices -> remoteStatus + Routes.Jam -> jamStatus + else -> null + }, onClick = { if (item.route == Routes.Library) { (item.data as? LibraryTab)?.let { @@ -194,9 +217,15 @@ fun AppSidebar( private fun SidebarNavItem( item: NavigationItem.Tab<*>, selected: Boolean, - onClick: () -> Unit + onClick: () -> Unit, + /** Live status (e.g. "Playing on Krtx"); when set the row gets a dot and a second line. */ + status: String? = null ) { val title = stringResource(item.title) + if (status != null) { + LiveSidebarNavItem(item, title, status, selected, onClick) + return + } MillenniumSidebarItem( label = title, onClick = onClick, @@ -205,3 +234,44 @@ private fun SidebarNavItem( icon = item.icon ) } + +@Composable +private fun LiveSidebarNavItem( + item: NavigationItem.Tab<*>, + title: String, + status: String, + selected: Boolean, + onClick: () -> Unit +) { + MillenniumSidebarItem( + onClick = onClick, + selected = selected, + contentDescription = "$title, $status" + ) { + // Match the text overload's icon metrics so live rows line up with their siblings. + val metrics = MillenniumTheme.sidebarTheme.item.resolve(MillenniumControlState.Default) + val contentColor = LocalTextStyle.current.color + Box { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(metrics.iconSize) + ) + LiveDot(Modifier.align(Alignment.TopEnd).offset(x = 3.dp, y = (-3).dp)) + } + if (LocalMillenniumSidebar.current?.collapsed != true) { + Spacer(Modifier.width(metrics.iconLabelSpacing)) + Column(Modifier.weight(1f)) { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = status, + style = AppTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt new file mode 100644 index 00000000..b2c14807 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt @@ -0,0 +1,131 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.spotube.core.ui.theming.AppTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.connect_jam_hosting +import spotube.composeapp.generated.resources.connect_jam_joined +import spotube.composeapp.generated.resources.devices_remote_playing_on +import spotube.composeapp.generated.resources.devices_status_connected + +/** + * Slim "something is live" strip under the Home header: one row while remote-controlling a device, + * one while in a Group Jam. Renders nothing when neither is active. + */ +@Composable +fun ConnectStatusBanner( + onOpenRemote: () -> Unit, + onOpenJam: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectStatusViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val remote = state.remote + val jam = state.jam + if (remote == null && jam == null) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (remote != null) StatusRow(remoteStatusLabel(remote), onOpenRemote) + if (jam != null) StatusRow(jamStatusLabel(jam), onOpenJam) + } +} + +@Composable +private fun StatusRow(label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(AppTheme.shapes.medium) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LiveDot() + Text( + text = label, + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } +} + +@Composable +internal fun remoteStatusLabel(status: RemoteConnectStatus): String = + status.deviceName?.let { stringResource(Res.string.devices_remote_playing_on, it) } + ?: stringResource(Res.string.devices_status_connected) + +@Composable +internal fun jamStatusLabel(status: JamConnectStatus): String = pluralStringResource( + if (status.isHost) Res.plurals.connect_jam_hosting else Res.plurals.connect_jam_joined, + status.listenerCount, + status.listenerCount) + +/** Small "something is live" marker, ringed so it reads on any background. */ +@Composable +internal fun LiveDot(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(10.dp) + .background(MillenniumTheme.colors.success, CircleShape) + .border(2.dp, MillenniumTheme.colors.surface, CircleShape) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt new file mode 100644 index 00000000..c83abac5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt @@ -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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient + +/** Set while this app is remote-controlling another device. */ +data class RemoteConnectStatus(val deviceName: String?) + +/** Set while this app is in a Group Jam room. */ +data class JamConnectStatus(val isHost: Boolean, val listenerCount: Int) + +data class ConnectStatusUiState( + val remote: RemoteConnectStatus? = null, + val jam: JamConnectStatus? = null) + +/** Live remote-control / Group Jam status shown by the Home header and the sidebar. */ +class ConnectStatusViewModel( + remoteControlClient: RemoteControlClient, + jamRoomService: JamRoomService +) : ViewModel() { + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + jamRoomService.role, + jamRoomService.participants + ) { connection, role, participants -> + ConnectStatusUiState( + remote = (connection as? ConnectionState.Connected)?.let { + RemoteConnectStatus(it.deviceName.ifBlank { null }) + }, + jam = role?.let { JamConnectStatus(isHost = it == JamRole.Host, listenerCount = participants.size) }) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ConnectStatusUiState()) +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit From 8bbefdfb7d6930f2cd7c1f91100825582e08717d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 16:03:48 +0600 Subject: [PATCH 090/132] fix(android): app stuck on splash screen from main-thread string loading PlaybackService.onCreate runs on the main thread and called runBlocking { getString(...) } for its notification strings. Compose resources share one in-flight load per strings file (AsyncCache creates the Deferred in the caller's scope); when App()'s stringResource started that load on the main dispatcher first, blocking main to await it deadlocked and the first frame never rendered. Timing-dependent, so mostly seen under the debugger. Create the notification channel immediately under the app name and update its localized name/description from serviceScope. The short-lived placeholder notification no longer carries localized text. --- .../spotube/spotube/media/PlaybackService.kt | 38 ++++++++++++------- .../composeResources/values-ar/strings.xml | 1 - .../composeResources/values-bn/strings.xml | 1 - .../composeResources/values-de/strings.xml | 1 - .../composeResources/values-es/strings.xml | 1 - .../composeResources/values-fa/strings.xml | 1 - .../composeResources/values-fr/strings.xml | 1 - .../composeResources/values-hi/strings.xml | 1 - .../composeResources/values-id/strings.xml | 1 - .../composeResources/values-it/strings.xml | 1 - .../composeResources/values-ja/strings.xml | 1 - .../composeResources/values-ko/strings.xml | 1 - .../composeResources/values-nl/strings.xml | 1 - .../composeResources/values-pl/strings.xml | 1 - .../composeResources/values-pt/strings.xml | 1 - .../composeResources/values-ru/strings.xml | 1 - .../composeResources/values-th/strings.xml | 1 - .../composeResources/values-tr/strings.xml | 1 - .../composeResources/values-uk/strings.xml | 1 - .../composeResources/values-vi/strings.xml | 1 - .../values-zh-rTW/strings.xml | 1 - .../composeResources/values-zh/strings.xml | 1 - 22 files changed, 24 insertions(+), 35 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt index 49b55ac4..bc3fdaf1 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -51,7 +51,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -59,7 +58,6 @@ import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.notification_playback_channel_description import spotube.composeapp.generated.resources.notification_playback_channel_name -import spotube.composeapp.generated.resources.notification_playback_service_text @OptIn(UnstableApi::class) class PlaybackService : MediaLibraryService(), KoinComponent { @@ -96,9 +94,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + // Short-lived placeholder until Media3 posts the playback notification. No localized text: + // loading it here would block the main thread (see createNotificationChannel). val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Spotube") - .setContentText(runBlocking { getString(Res.string.notification_playback_service_text) }) .setSmallIcon(R.drawable.ic_stat_spotube) .setContentIntent(sessionActivity) .setOngoing(true) @@ -125,21 +124,32 @@ class PlaybackService : MediaLibraryService(), KoinComponent { Log.i(TAG, "Library session created") } + /** + * The channel must exist before startForeground, so it's created right away under the app name + * and renamed once the localized strings load. + * + * Never `runBlocking { getString(...) }` here: onCreate runs on the main thread, and Compose + * resources share one in-flight load per strings file. If App()'s stringResource started that + * load on the main dispatcher first, blocking main to await it deadlocks the app on its + * splash screen. + */ @SuppressLint("ObsoleteSdkInt") private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - runBlocking { getString(Res.string.notification_playback_channel_name) }, - NotificationManager.IMPORTANCE_LOW - ).apply { - description = runBlocking { - getString(Res.string.notification_playback_channel_description) - } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + fun channel(name: String, description: String? = null) = + NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply { + this.description = description setShowBadge(false) } - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) + + manager.createNotificationChannel(channel("Spotube")) + serviceScope.launch { + // Re-creating a channel with the same ID updates its name and description. + manager.createNotificationChannel( + channel( + name = getString(Res.string.notification_playback_channel_name), + description = getString(Res.string.notification_playback_channel_description))) } } diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index febc7e6a..6671d6d9 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -938,7 +938,6 @@ إغلاق - خدمة التشغيل التشغيل عناصر التحكم في تشغيل الوسائط diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index ca7c78cf..a3c26ed2 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -914,7 +914,6 @@ বন্ধ করুন - প্লেব্যাক সার্ভিস প্লেব্যাক মিডিয়া প্লেব্যাক কন্ট্রোল diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index ec3d8da3..4a4115dd 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -914,7 +914,6 @@ Schließen - Wiedergabedienst Wiedergabe Steuerung der Medienwiedergabe diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 4fec22e5..48c78bf3 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -920,7 +920,6 @@ Cerrar - Servicio de reproducción Reproducción Controles de reproducción multimedia diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 679313d5..8a5659ed 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -914,7 +914,6 @@ بستن - سرویس پخش پخش کنترل‌های پخش رسانه diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 644cb9f9..2737c4bc 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -920,7 +920,6 @@ Fermer - Service de lecture Lecture Commandes de lecture multimédia diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 4deeb649..2ed9b459 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -913,7 +913,6 @@ बंद करें - प्लेबैक सेवा प्लेबैक मीडिया प्लेबैक कंट्रोल diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 27c80b88..f3e1ca6a 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -908,7 +908,6 @@ Tutup - Layanan pemutaran Pemutaran Kontrol pemutaran media diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 36196910..b6925383 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -920,7 +920,6 @@ Chiudi - Servizio di riproduzione Riproduzione Controlli di riproduzione multimediale diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index c6b4195d..a915d409 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -908,7 +908,6 @@ 閉じる - 再生サービス 再生 メディア再生コントロール diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index cad8b072..5464dc07 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -908,7 +908,6 @@ 닫기 - 재생 서비스 재생 미디어 재생 컨트롤 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index 3206d0d6..337ad03c 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -914,7 +914,6 @@ Sluiten - Afspeelservice Afspelen Bediening voor media-afspelen diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index fa6bcfb6..f5b66965 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -926,7 +926,6 @@ Zamknij - Usługa odtwarzania Odtwarzanie Sterowanie odtwarzaniem multimediów diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index f7c11447..fed0a741 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -920,7 +920,6 @@ Fechar - Serviço de reprodução Reprodução Controles de reprodução de mídia diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index 28b5450e..e499d01d 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -926,7 +926,6 @@ Закрыть - Служба воспроизведения Воспроизведение Управление воспроизведением diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 2b14b08d..5c5b861d 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -908,7 +908,6 @@ ปิด - บริการเล่นเพลง การเล่น ตัวควบคุมการเล่นสื่อ diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 123d18cd..4999b886 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -914,7 +914,6 @@ Kapat - Çalma hizmeti Çalma Medya çalma kontrolleri diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index 0fbd6d0e..1f07bb5d 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -926,7 +926,6 @@ Закрити - Служба відтворення Відтворення Керування відтворенням diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 241c50f4..b6d4f5ea 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -908,7 +908,6 @@ Đóng - Dịch vụ phát nhạc Phát nhạc Điều khiển phát nội dung diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index e10e720e..f5907323 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -908,7 +908,6 @@ 關閉 - 播放服務 播放 媒體播放控制項 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index b247fac7..d154c6a2 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -908,7 +908,6 @@ 关闭 - 播放服务 播放 媒体播放控制 From 08892c2aa0d6773e9100c5847cd6f779621d38cd Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 16:03:48 +0600 Subject: [PATCH 091/132] fix(downloads): respect download format/quality and never save manifests or video - Downloads resolve streams with the user's download format/quality via a new StreamRequest instead of the streaming settings, and bypass the playback stream-URL cache in both directions - Downloads only consider progressive streams; DASH/HLS manifests were previously saved as-is instead of audio - NewPipe: map AAC "mp4a.40.x"/"m4a" to the advertised "aac"/"mp4" so real AAC streams can match (previously the hard-coded aac/mp4 manifests always won), report each stream's real delivery method, skip streams without a format or non-URL content - Fail the download on non-2xx responses, on a range request answered with a full 200, and on video/* content, with localized reasons, instead of writing the response body to disk - Save AAC as .m4a - Tests for request overrides, manifest exclusion and cache isolation --- .../core/newpipe/NewPipeService.androidJvm.kt | 30 ++++++++- .../composeResources/values/strings.xml | 4 +- .../core/server/StreamingUrlRepository.kt | 60 +++++++++++------ .../modules/downloads/DownloadManager.kt | 57 +++++++++++++---- .../StreamingUrlRepositoryBehaviorTest.kt | 64 +++++++++++++++++++ 5 files changed, 182 insertions(+), 33 deletions(-) diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index 7e4311dc..a79f5a03 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory +import org.schabi.newpipe.extractor.stream.DeliveryMethod import org.schabi.newpipe.extractor.stream.StreamInfoItem /** NewPipe reports durations in seconds (-1 when unknown). */ @@ -88,11 +89,15 @@ actual class NewPipeService { val audioStreams = extractor.audioStreams .mapNotNull { stream -> + val format = stream.format ?: return@mapNotNull null + // Non-URL content is an inline manifest, not something a player or downloader can fetch. + if (!stream.isUrl) return@mapNotNull null AudioStream.Lossy( url = stream.content, - codec = stream.codec, - container = stream.format!!.suffix, - bitrate = stream.bitrate) + codec = normalizeCodec(stream.codec), + container = normalizeContainer(format.suffix), + bitrate = stream.bitrate, + protocol = stream.deliveryMethod.toProtocol()) } + buildList { if (extractor.dashMpdUrl.isNotBlank()) add( AudioStream.Lossy( @@ -151,3 +156,22 @@ actual class NewPipeService { } } } + +/** + * NewPipe names AAC "mp4a.40.x" in an "m4a" container, but the plugin advertises (and users pick) + * "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded + * aac/mp4 DASH/HLS manifests always won instead. + */ +private fun normalizeCodec(codec: String?): String { + val value = codec.orEmpty() + return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value +} + +private fun normalizeContainer(suffix: String): String = + if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix + +private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) { + DeliveryMethod.DASH -> StreamProtocol.DASH + DeliveryMethod.HLS -> StreamProtocol.HLS + else -> StreamProtocol.PROGRESSIVE +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 9cfcb895..a5481e2e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -710,6 +710,9 @@ No track metadata Could not resolve stream URL Could not resolve stream URL. Try playing the track first. + The server refused the download (HTTP %1$d) + The server sent an incomplete download. Try again. + Only a video is available for this track, so it wasn\'t downloaded Saved Tracks @@ -962,7 +965,6 @@ Close - Playback service Playback Media playback controls diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 3a20b697..3ee50fa0 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -54,6 +54,16 @@ private data class CachedAlternatives( val pluginId: String, val sources: List) +/** + * Stream preferences for callers other than playback. The downloader passes the user's *download* + * format/quality and needs a file it can fetch with plain HTTP, so DASH/HLS manifests are excluded. + * Such requests bypass the playback stream-URL cache in both directions. + */ +data class StreamRequest( + val format: AudioFormat, + val quality: AudioQuality, + val progressiveOnly: Boolean) + data class StreamInfo( val url: String, val protocol: StreamProtocol, @@ -90,26 +100,44 @@ class StreamingUrlRepository( suspend fun resolveStreamInfo( track: MetadataTrack, - forceRefresh: Boolean = false + forceRefresh: Boolean = false, + request: StreamRequest? = null ): StreamInfo? { val trackId = track.id val settings = settingsRepository.userSettings.value - val preferredFormat = settings.streamingMusicFormat - val preferredQuality = settings.streamingMusicQuality + val preferredFormat = request?.format ?: settings.streamingMusicFormat + val preferredQuality = request?.quality ?: settings.streamingMusicQuality + // The URL cache holds what *playback* resolved; a download with other prefs must not + // read it, nor replace it with a stream playback didn't ask for. + val useUrlCache = request == null val audioPlugin = pluginManager.selectedAudioPlugin.value if (audioPlugin == null) { logger.w { "No audio plugin selected while resolving stream for track $trackId" } return null } - // NOTE: always the *streaming* preferences, also when called by the downloader. logger.i { "[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " + "prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " + - "(streamingMusicFormat/streamingMusicQuality)" + if (request == null) "(streaming settings)" else "(caller request, progressiveOnly=${request.progressiveOnly})" } - if (!forceRefresh) { + fun select(stream: AudioSource.Streamed, origin: String): AudioStream? { + val candidates = if (request?.progressiveOnly == true) { + stream.streams.filter { it.protocol == StreamProtocol.PROGRESSIVE }.also { kept -> + if (kept.size != stream.streams.size) logger.i { + "[Resolve $trackId] dropped ${stream.streams.size - kept.size} manifest (DASH/HLS) candidates; " + + "caller needs a directly downloadable file" + } + } + } else { + stream.streams + } + return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) + .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } + } + + if (useUrlCache && !forceRefresh) { getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> logger.i { "[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " + @@ -135,14 +163,12 @@ class StreamingUrlRepository( if (stream != null) { logger.d { "Resolved stream for track $trackId using cached source" } - val resolvedStream = selectPreferredAudioStream( - stream.streams, - preferredFormat, - preferredQuality) - logSelection(trackId, "matched source ${source.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream) + val resolvedStream = select(stream, "matched source ${source.id}") if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() - cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + if (useUrlCache) { + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + } return info } } @@ -195,14 +221,12 @@ class StreamingUrlRepository( } logger.d { "Resolved stream for track $trackId via track lookup" } - val resolvedStream = selectPreferredAudioStream( - stream.streams, - preferredFormat, - preferredQuality) - logSelection(trackId, "track lookup -> ${stream.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream) + val resolvedStream = select(stream, "track lookup -> ${stream.id}") if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() - cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + if (useUrlCache) { + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + } return info } return null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index d2f91aae..9b1824a0 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -21,6 +21,7 @@ import co.touchlab.kermit.Logger import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.server.StreamInfo +import org.spotube.spotube.core.server.StreamRequest import org.spotube.spotube.core.server.StreamingUrlRepository import org.spotube.spotube.core.server.urlSummary import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol @@ -32,6 +33,7 @@ import io.ktor.client.request.head import io.ktor.client.request.headers import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode import io.ktor.http.contentLength import io.ktor.http.isSuccess import io.ktor.utils.io.readRemaining @@ -226,7 +228,14 @@ class DownloadManager( try { logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" } - val streamInfo = streamingUrlRepository.resolveStreamInfo(track) + val settings = settingsRepository.userSettings.value + val streamInfo = streamingUrlRepository.resolveStreamInfo( + track, + request = StreamRequest( + format = settings.downloadMusicFormat, + quality = settings.downloadMusicQuality, + // A manifest URL would be saved as-is instead of the audio it points to. + progressiveOnly = true)) if (streamInfo == null) { logger.w { "Could not resolve stream URL for ${track.title}" } val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short) @@ -358,9 +367,15 @@ class DownloadManager( "HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " + "range=${response.headers[HttpHeaders.ContentRange]}" } - // expectSuccess = false, so an error page would otherwise be saved as audio. + // expectSuccess = false, so without these checks an error page, or the whole file once per + // segment (a 200 to a range request), would be written into the "audio" file. if (!response.status.isSuccess()) { - logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; its body will be written as audio" } + logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; failing download" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) + } + if (response.status != HttpStatusCode.PartialContent) { + logger.w { "[Download ${item.id}] segment ${segment.index} got ${response.status} instead of 206; range ignored" } + throw DownloadException(getString(Res.string.downloads_error_range_ignored)) } val channel = response.bodyAsChannel() @@ -389,8 +404,10 @@ class DownloadManager( "type=${response.headers[HttpHeaders.ContentType]} length=$totalSize" } if (!response.status.isSuccess()) { - logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; its body will be written as audio" } + logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; failing download" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) } + rejectVideo(item.id, response.headers[HttpHeaders.ContentType]) val channel = response.bodyAsChannel() fileSystem.sink(tempFile).buffer().use { sink -> @@ -433,16 +450,27 @@ class DownloadManager( "[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + "length=$contentLength acceptRanges=$acceptRanges" } - if (contentType?.startsWith("video/") == true) { - logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio" } - } + rejectVideo(id, contentType) Pair(contentLength, supportsRanges) + } catch (e: DownloadException) { + throw e } catch (e: Exception) { logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" } Pair(0L, false) } } + /** + * Some sources only offer muxed video (e.g. NewPipe falls back to video streams when a video + * has no audio-only ones). Saving that as a "song" is worse than failing with a clear reason. + */ + private suspend fun rejectVideo(id: String, contentType: String?) { + if (contentType?.startsWith("video/", ignoreCase = true) == true) { + logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio; failing download" } + throw DownloadException(getString(Res.string.downloads_error_video_only)) + } + } + private fun logResolvedStream(item: DownloadItem, info: StreamInfo) { val settings = settingsRepository.userSettings.value val wanted = settings.downloadMusicFormat @@ -457,9 +485,8 @@ class DownloadManager( } if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) { logger.w { - "[Download ${item.id}] DOWNLOAD FORMAT NOT RESPECTED: download setting is ${wanted.codec}/${wanted.container} " + - "${settings.downloadMusicQuality}, got ${info.codec}/${info.container} " + - "(resolver used streaming prefs ${settings.streamingMusicFormat.codec}/${settings.streamingMusicFormat.container})" + "[Download ${item.id}] FORMAT FALLBACK: download setting is ${wanted.codec}/${wanted.container} " + + "${settings.downloadMusicQuality}, but the closest downloadable stream offered is ${info.codec}/${info.container}" } } } @@ -525,13 +552,21 @@ class DownloadManager( private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String { val safeTitle = downloadItem.title.sanitizeFilename() val safeArtist = downloadItem.artists.sanitizeFilename() - val extension = streamInfo.container + val extension = fileExtension(streamInfo) return "$safeArtist - $safeTitle.$extension" } + /** AAC in an MP4 container is audio-only, so give it the audio extension players expect. */ + private fun fileExtension(info: StreamInfo): String = + if (info.codec.equals("aac", ignoreCase = true) && info.container.equals("mp4", ignoreCase = true)) "m4a" + else info.container + private fun String.sanitizeFilename(): String { return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200) } private data class Segment(val index: Int, val start: Long, val end: Long) } + +/** A download failure whose [message] is already localized and safe to show the user. */ +private class DownloadException(message: String) : Exception(message) diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index b956d423..d7bda94a 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -545,4 +545,68 @@ class StreamingUrlRepositoryBehaviorTest { // Fetching the entry doesn't call the plugin again. assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) } + + // ---------- caller requests (downloads) ---------- + + private val aacRequest = StreamRequest( + format = org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat( + codec = "aac", container = "mp4", qualities = listOf(AudioQuality.Lossy(128_000))), + quality = AudioQuality.Lossy(128_000), + progressiveOnly = true) + + private fun aacStream(url: String, protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy( + url = url, codec = "aac", container = "mp4", protocol = protocol, bitrate = 128_000) + + @Test + fun `a request's format overrides the streaming settings`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"), aacStream("https://example.com/a.m4a")))) + } + + val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertEquals("https://example.com/a.m4a", info?.url) + } + + @Test + fun `progressiveOnly never picks a manifest even when it matches best`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf( + aacStream("https://example.com/manifest.mpd", StreamProtocol.DASH), + stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertEquals("https://example.com/a.opus", info?.url) + assertEquals(StreamProtocol.PROGRESSIVE, info?.protocol) + } + + @Test + fun `progressiveOnly returns null when only manifests exist`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(aacStream("https://example.com/manifest.m3u8", StreamProtocol.HLS)))) + } + + assertNull(h.repository.resolveStreamInfo(track("t1"), request = aacRequest)) + } + + @Test + fun `a request does not write the playback url cache`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(aacStream("https://example.com/a.m4a")))) + } + + h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertNull(h.repository.getCachedStreamUrlEntry("t1")) + } } From 79d47c1d874c761f8d32a98a46ecefb88ea34a25 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 20:32:47 +0600 Subject: [PATCH 092/132] feat: enhance loading states for various UI components and update dependencies --- .../org/spotube/spotube/core/di/Modules.kt | 2 + .../core/remote/RemotePlaybackController.kt | 202 +++++++++++------- .../spotube/core/ui/component/AlbumCard.kt | 7 +- .../core/ui/component/CollectionDetails.kt | 11 +- .../core/ui/component/CollectionView.kt | 13 ++ .../spotube/core/ui/component/ErrorDisplay.kt | 1 + .../core/ui/component/FeaturedCarousel.kt | 32 ++- .../core/ui/component/LikedTracksCard.kt | 38 +++- .../core/ui/component/LoginRequiredDisplay.kt | 4 +- .../spotube/core/ui/component/PlaylistCard.kt | 7 +- .../core/ui/component/cards/PlayableCard.kt | 13 +- .../ui/component/cards/PlayableListTile.kt | 4 + .../spotube/modules/album/AlbumScreen.kt | 5 + .../spotube/modules/album/AlbumViewModel.kt | 36 +++- .../spotube/modules/artist/ArtistScreen.kt | 19 +- .../spotube/modules/artist/ArtistViewModel.kt | 33 ++- .../spotube/modules/devices/DevicesScreen.kt | 25 +-- .../modules/devices/DevicesViewModel.kt | 19 +- .../spotube/spotube/modules/jam/JamScreen.kt | 3 +- .../spotube/modules/jam/JamViewModel.kt | 20 +- .../spotube/modules/library/LibraryScreen.kt | 2 +- .../spotube/modules/library/LibraryState.kt | 13 +- .../LibraryLocalTracksViewModel.kt | 27 ++- .../playlist/LibraryPlaylistsScreen.kt | 8 +- .../playlist/LibraryPlaylistsViewModel.kt | 57 +++-- .../playlist/PlaylistFormController.kt | 96 +++++++++ .../library/playlist/PlaylistFormSheet.kt | 51 ++--- .../playlist/AddTracksToPlaylistDialog.kt | 105 +++------ .../playlist/AddTracksToPlaylistViewModel.kt | 131 ++++++++++++ .../modules/playlist/PlaylistScreen.kt | 24 +-- .../modules/playlist/PlaylistViewModel.kt | 67 ++++-- .../modules/plugin/MetadataAuthViewModel.kt | 25 ++- .../spotube/modules/plugin/PluginManager.kt | 32 +-- .../spotube/modules/plugin/PluginScreen.kt | 47 ++-- .../spotube/modules/plugin/PluginViewModel.kt | 85 ++++++-- .../modules/plugin/components/PluginCard.kt | 6 +- .../plugin/components/PluginDialogParts.kt | 4 +- .../components/PluginPermissionsDialog.kt | 4 +- .../plugin/guard/MetadataPluginGuard.kt | 1 + .../modules/saved_tracks/SavedTracksScreen.kt | 4 + .../saved_tracks/SavedTracksViewModel.kt | 28 +++ .../modules/settings/JamSettingsScreen.kt | 10 +- .../modules/settings/JamSettingsViewModel.kt | 8 +- .../settings/LocalMediaFoldersViewModel.kt | 2 +- .../components/LocalMediaFoldersEditor.kt | 4 +- .../modules/shell/PlayerHeartButton.kt | 19 +- gradle/libs.versions.toml | 2 +- 47 files changed, 938 insertions(+), 418 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index 5022fcd5..05ec4253 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -67,6 +67,7 @@ import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersC import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.playlist.AddTracksToPlaylistViewModel import org.spotube.spotube.modules.playlist.PlaylistRepository import org.spotube.spotube.modules.playlist.PlaylistViewModel import org.spotube.spotube.modules.plugin.AudioPluginSource @@ -185,6 +186,7 @@ val sharedModules = module { downloadManager = get(), remotePlaybackController = get()) } + viewModel { (playlistId: String) -> AddTracksToPlaylistViewModel(playlistId, get(), get()) } // Saved Tracks singleOf(::SavedTracksRepository) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt index d4442f15..3b37ff81 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt @@ -28,12 +28,16 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getPluralString @@ -82,6 +86,22 @@ sealed interface PlaybackDestinationRequest { val tracks: List) : PlaybackDestinationRequest } +/** + * Identifies a whole-collection action (e.g. "shuffle album X") while its tracks are being + * fetched and queued locally, so the button that started it can show a loading state. + */ +data class CollectionPlaybackKey( + val type: RemoteCollectionType, + val id: String, + val action: PlaybackDestinationAction, + val shuffle: Boolean = false) + +/** Loading flags for a collection screen's play / shuffle / add-to-queue buttons. */ +data class CollectionPlaybackUiState( + val isPlayLoading: Boolean = false, + val isShuffleLoading: Boolean = false, + val isAddToQueueLoading: Boolean = false) + /** * Routes playback actions (play / add to queue / play next) to either the local * device or a connected remote device. When a remote device is connected the @@ -103,6 +123,18 @@ class RemotePlaybackController( private val _events = MutableSharedFlow(extraBufferCapacity = 8) val events: SharedFlow = _events.asSharedFlow() + /** Collection actions currently running locally; see [CollectionPlaybackKey]. */ + private val _busyCollections = MutableStateFlow>(emptySet()) + val busyCollections: StateFlow> = _busyCollections.asStateFlow() + + fun collectionPlaybackUiState(type: RemoteCollectionType, id: String): Flow = + busyCollections.map { busy -> + CollectionPlaybackUiState( + isPlayLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play) in busy, + isShuffleLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play, shuffle = true) in busy, + isAddToQueueLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.AddToQueue) in busy) + }.distinctUntilChanged() + fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } @@ -230,103 +262,119 @@ class RemotePlaybackController( } private fun executeLocally(request: PlaybackDestinationRequest) { + // Starting from a specific track (a tap in a track list) isn't tied to a collection button. + val busyKey = (request as? PlaybackDestinationRequest.Collection) + ?.takeIf { it.startTrack == null } + ?.let { CollectionPlaybackKey(it.type, it.id, it.action, it.shuffle) } + if (busyKey != null && busyKey in _busyCollections.value) return + busyKey?.let { key -> _busyCollections.update { it + key } } scope.launch { - when (request) { - is PlaybackDestinationRequest.Collection -> { - val startTrack = request.startTrack - when (request.type) { - RemoteCollectionType.Playlist -> when (request.action) { - PlaybackDestinationAction.Play -> { - if (startTrack != null) { - collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) - } else { - collectionPlaybackHelper.playPlaylist(request.id, request.shuffle) - } + try { + executeLocallyNow(request) + } catch (e: Exception) { + logger.e(e) { "Failed to play ${request.title} locally" } + } finally { + busyKey?.let { key -> _busyCollections.update { it - key } } + } + } + } + + private suspend fun executeLocallyNow(request: PlaybackDestinationRequest) { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id, request.shuffle) } - - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) } - RemoteCollectionType.Album -> when (request.action) { - PlaybackDestinationAction.Play -> { - if (startTrack != null) { - collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) - } else { - collectionPlaybackHelper.playAlbum(request.id, request.shuffle) - } + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id, request.shuffle) } - - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) } - RemoteCollectionType.ArtistTopTracks -> when (request.action) { - PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) - } + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } - RemoteCollectionType.SavedTracks -> when (request.action) { - PlaybackDestinationAction.Play -> { - if (startTrack != null) { - collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) - } else { - collectionPlaybackHelper.playSavedTracks(request.shuffle) - } + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks(request.shuffle) } - - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() } } + } - is PlaybackDestinationRequest.Track -> { - val entry = QueueEntry.StreamingTrack(track = request.track, url = "") - when (request.action) { - PlaybackDestinationAction.Play -> { - audioPlayerQueue.load( - entries = listOf(entry), - autoPlay = true, - startPosition = 0, - collectionEntry = null) - } + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } - PlaybackDestinationAction.AddToQueue -> { - audioPlayerQueue.addToQueue(entry) - } + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } - PlaybackDestinationAction.PlayNext -> { - val queue = audioPlayerQueue.getQueue() - queue.find { candidate -> - (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(entry)) - } + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) } } + } - is PlaybackDestinationRequest.Tracks -> { - val entries = request.tracks - .filter { track -> !isTrackBlacklisted(track) } - .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } - when (request.action) { - PlaybackDestinationAction.Play -> { - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = 0, - collectionEntry = null) - } + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } - PlaybackDestinationAction.AddToQueue -> { - audioPlayerQueue.addAllToQueue(entries) - } + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } - PlaybackDestinationAction.PlayNext -> { - audioPlayerQueue.addAllAfterCurrent(entries) - } + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt index 40694847..b651edbf 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt @@ -28,6 +28,8 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.ui.component.cards.PlayableCard @@ -44,6 +46,7 @@ fun AlbumCard( listMode: Boolean = false ) { val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() PlayableCard( title = album.title, @@ -71,6 +74,8 @@ fun AlbumCard( }, modifier = if (listMode) modifier else modifier.width(160.dp), sharedElementKey = "album_art_${album.id}", - listMode = listMode + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.AddToQueue) in busyCollections ) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt index 1ac2597e..84901d48 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt @@ -88,6 +88,10 @@ fun CollectionDetails( isJamGuest: Boolean = false, onAddToJam: (() -> Unit)? = null, isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, @@ -126,7 +130,8 @@ fun CollectionDetails( MillenniumButton( modifier = modifier, - onClick = onPlay) { + onClick = onPlay, + loading = isPlayLoading) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) @@ -135,6 +140,7 @@ fun CollectionDetails( MillenniumButton( modifier = modifier, onClick = onShufflePlay, + loading = isShuffleLoading, style = MillenniumButtonStyle.Secondary ) { MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play)) @@ -148,6 +154,7 @@ fun CollectionDetails( Row(verticalAlignment = Alignment.CenterVertically) { MillenniumIconButton( onClick = onAddToQueue, + loading = isAddToQueueLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, @@ -159,6 +166,7 @@ fun CollectionDetails( if (isFollowing) { MillenniumIconButton( onClick = onFollowClick, + loading = isFollowLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxHeart2, @@ -167,6 +175,7 @@ fun CollectionDetails( } else { MillenniumIconButton( onClick = onFollowClick, + loading = isFollowLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxHeart, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt index 678dfc5b..e29aa918 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt @@ -69,6 +69,10 @@ fun CollectionView( onAddToQueue: () -> Unit, isPlaying: Boolean = false, isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, onFollowClick: () -> Unit = {}, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, @@ -123,6 +127,7 @@ fun CollectionView( { MillenniumIconButton( onClick = onPlay, + loading = isPlayLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, @@ -159,6 +164,10 @@ fun CollectionView( onAddToQueue = onAddToQueue, isPlaying = isPlaying, isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, onFollowClick = onFollowClick, showFollowButton = showFollowButton, onEdit = onEdit, @@ -180,6 +189,10 @@ fun CollectionView( onAddToQueue = onAddToQueue, isPlaying = isPlaying, isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, onFollowClick = onFollowClick, showFollowButton = showFollowButton, onEdit = onEdit, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt index 753947e4..a5484b68 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt @@ -87,6 +87,7 @@ fun ErrorDisplay( LoginRequiredDisplay( pluginName = authState.pluginName, onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, modifier = modifier) return } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt index 33ff15e6..6c768959 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt @@ -72,7 +72,10 @@ import org.spotube.spotube.core.audioplayer.QueueCollectionEntry import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes -import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.ui.coverflow.Coverflow import org.spotube.spotube.core.ui.coverflow.CoverflowParams import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState @@ -288,11 +291,12 @@ private fun FeaturedPlayButton( item: MetadataBrowseItem, audioPlayerQueue: AudioPlayerQueue, navigationCommands: NavigationCommands, - playbackHelper: CollectionPlaybackHelper = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), enabled: Boolean, modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() when (item) { is MetadataBrowseItem.Album -> { @@ -309,10 +313,18 @@ private fun FeaturedPlayButton( if (isPlaying) { navigationCommands.navigateTo(Routes.Album(item.data.id)) } else { - scope.launch { playbackHelper.playAlbum(item.data.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + item.data.id, + item.data.title + ) } }, - enabled = enabled + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Album, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White ) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, @@ -337,10 +349,18 @@ private fun FeaturedPlayButton( if (isPlaying) { navigationCommands.navigateTo(Routes.Playlist(item.data.id)) } else { - scope.launch { playbackHelper.playPlaylist(item.data.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + item.data.id, + item.data.title + ) } }, - enabled = enabled + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Playlist, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White ) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt index 33ec72c8..98439207 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt @@ -38,7 +38,6 @@ import com.composeunstyled.Text import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,14 +49,17 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueCollectionEntry import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes -import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxAddSquare import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle import org.spotube.spotube.resources.iconsax.Play -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import spotube.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import org.spotube.spotube.core.ui.component.cards.PlayableListTile @@ -72,14 +74,26 @@ import androidx.compose.ui.platform.LocalInputModeManager fun LikedTracksCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), - playbackHelper: CollectionPlaybackHelper = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), navigationCommands: NavigationCommands = koinInject(), listMode: Boolean = false ) { - val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks val title = stringResource(Res.string.media_liked_tracks) + val playLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.Play + ) in busyCollections + val addToQueueLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.AddToQueue + ) in busyCollections + val onPlay = { + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } + val onAddToQueue = { + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } val subtitle = stringResource(Res.string.media_saved_songs) if (listMode) { PlayableListTile( @@ -87,10 +101,12 @@ fun LikedTracksCard( subtitle = subtitle, isPlaying = isPlaying, onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, - onPlay = { scope.launch { playbackHelper.playSavedTracks() } }, - onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, + onPlay = onPlay, + onAddToQueue = onAddToQueue, modifier = modifier, sharedElementKey = "saved_tracks_art", + playLoading = playLoading, + addToQueueLoading = addToQueueLoading, artwork = { Image( painterResource(Res.drawable.liked_tracks), @@ -132,13 +148,14 @@ fun LikedTracksCard( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), contentAlignment = Alignment.BottomEnd ) { - if (isHovered || touchInput || isPlaying) { + if (isHovered || touchInput || isPlaying || playLoading || addToQueueLoading) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { MillenniumIconButton( - onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, + onClick = onAddToQueue, + loading = addToQueueLoading, modifier = Modifier.size(30.dp), style = MillenniumIconButtonStyle.Secondary ) { @@ -150,7 +167,8 @@ fun LikedTracksCard( } Spacer(modifier = Modifier.height(8.dp)) MillenniumIconButton( - onClick = { scope.launch { playbackHelper.playSavedTracks() } }, + onClick = onPlay, + loading = playLoading, modifier = Modifier.size(30.dp), style = MillenniumIconButtonStyle.Primary ) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt index 77a84021..f65f5b9a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt @@ -49,7 +49,8 @@ import spotube.composeapp.generated.resources.* fun LoginRequiredDisplay( pluginName: String?, onLogin: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier, + isLoggingIn: Boolean = false) { Column( modifier = modifier .fillMaxWidth() @@ -88,6 +89,7 @@ fun LoginRequiredDisplay( MillenniumButton( onClick = onLogin, + loading = isLoggingIn, modifier = Modifier.height(44.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { MillenniumIcon( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt index d29fa1c4..ecb12b79 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt @@ -27,6 +27,8 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.ui.component.cards.PlayableCard @@ -45,6 +47,7 @@ fun PlaylistCard( ) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() PlayableCard( title = playlist.title, @@ -71,6 +74,8 @@ fun PlaylistCard( }, modifier = modifier, sharedElementKey = "playlist_art_${playlist.id}", - listMode = listMode + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.AddToQueue) in busyCollections ) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt index c8b98a14..8f9e81d8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt @@ -76,7 +76,9 @@ fun PlayableCard( onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, sharedElementKey: String? = null, - listMode: Boolean = false + listMode: Boolean = false, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false ) { if (listMode) { PlayableListTile( @@ -88,7 +90,9 @@ fun PlayableCard( onPlay, onAddToQueue, modifier, - sharedElementKey + sharedElementKey, + playLoading = playLoading, + addToQueueLoading = addToQueueLoading ) return } @@ -124,7 +128,8 @@ fun PlayableCard( contentAlignment = Alignment.BottomEnd ) { this@Column.AnimatedVisibility( - visible = highlighted || touchInput || (isPlaying && onPlay != null), + visible = highlighted || touchInput || (isPlaying && onPlay != null) || + playLoading || addToQueueLoading, enter = fadeIn(), exit = fadeOut() ) { @@ -137,6 +142,7 @@ fun PlayableCard( onAddToQueue?.takeIf { !isPlaying }?.let { MillenniumIconButton( onClick = onAddToQueue, + loading = addToQueueLoading, modifier = Modifier.size(34.dp), style = MillenniumIconButtonStyle.Secondary ) { @@ -152,6 +158,7 @@ fun PlayableCard( onPlay?.let { MillenniumIconButton( onClick = onPlay, + loading = playLoading, modifier = Modifier.size(34.dp), style = MillenniumIconButtonStyle.Primary diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt index f01bcb0f..abc69cda 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt @@ -56,6 +56,8 @@ fun PlayableListTile( onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, sharedElementKey: String? = null, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false, imageModel: Any? = imageURL, imageShape: Shape = RoundedCornerShape(6.dp), placeholderIcon: ImageVector = Iconsax.IconsaxMusic, @@ -115,6 +117,7 @@ fun PlayableListTile( if (onAddToQueue != null && !isPlaying) { MillenniumIconButton( onClick = onAddToQueue, + loading = addToQueueLoading, modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Secondary ) { @@ -127,6 +130,7 @@ fun PlayableListTile( if (onPlay != null) { MillenniumIconButton( onClick = onPlay, + loading = playLoading, modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Primary ) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt index 50d690f2..bd134ba8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt @@ -45,6 +45,7 @@ fun AlbumScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -71,6 +72,9 @@ fun AlbumScreen( val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, title = album?.title ?: stringResource(Res.string.album_loading_title), description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}", imageURL = artworkUrl, @@ -86,6 +90,7 @@ fun AlbumScreen( onAddToQueue = viewModel::addAlbumToQueue, isPlaying = isAlbumPlaying, isFollowing = savedAlbumIds.contains(albumId), + isFollowLoading = dataState?.isSaving == true, onFollowClick = viewModel::toggleSavedAlbum, isLoading = isLoading, error = errorMessage, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt index 0ba58bd4..eced34d5 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt @@ -26,6 +26,7 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -47,6 +48,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString @@ -104,6 +106,11 @@ class AlbumViewModel( private val _state = MutableStateFlow(AlbumScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Album, albumId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedAlbumIds get() = libraryRepository.savedAlbumIdsFlow @@ -203,13 +210,30 @@ class AlbumViewModel( } fun toggleSavedAlbum() { + if ((_state.value as? AlbumScreenState.Data)?.isSaving == true) return + setSaving(true) viewModelScope.launch { - val isLiked = - libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false - if (isLiked) { - libraryRepository.removeSavedAlbums(listOf(albumId)) - } else { - libraryRepository.saveAlbums(listOf(albumId)) + runCatching { + val isLiked = + libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedAlbums(listOf(albumId)) + } else { + libraryRepository.saveAlbums(listOf(albumId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved album" } + } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is AlbumScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is AlbumScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt index 30bda22f..68d5560b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt @@ -104,6 +104,7 @@ fun ArtistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.state.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -149,6 +150,7 @@ fun ArtistScreen( ArtistHeaderCard( artist = currentState.artist, isSaved = savedArtistIds.contains(currentState.artist.id), + isSaving = currentState.isSaving, onFollowClick = viewModel::toggleSavedArtist, isBlacklisted = currentState.artist.id in blacklistedArtistIds, onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) @@ -157,7 +159,9 @@ fun ArtistScreen( item { TopTracksHeader( onPlay = viewModel::playTopTracks, - onAddToQueue = viewModel::addTopTracksToQueue + onAddToQueue = viewModel::addTopTracksToQueue, + isPlayLoading = playbackUiState.isPlayLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading ) } @@ -345,6 +349,7 @@ private fun ArtistErrorContent( private fun ArtistHeaderCard( artist: MetadataArtist.Detailed, isSaved: Boolean, + isSaving: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, onBlacklistClick: () -> Unit @@ -377,6 +382,7 @@ private fun ArtistHeaderCard( ArtistHeaderActions( isSaved = isSaved, + isSaving = isSaving, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, onBlacklistClick = onBlacklistClick @@ -406,6 +412,7 @@ private fun ArtistHeaderCard( ArtistHeaderActions( isSaved = isSaved, + isSaving = isSaving, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, onBlacklistClick = onBlacklistClick @@ -496,6 +503,7 @@ private fun ArtistMeta( @Composable private fun ArtistHeaderActions( isSaved: Boolean, + isSaving: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, onBlacklistClick: () -> Unit @@ -507,6 +515,7 @@ private fun ArtistHeaderActions( if (isSaved) { MillenniumButton( onClick = onFollowClick, + loading = isSaving, style = MillenniumButtonStyle.Secondary ) { Text( @@ -517,7 +526,7 @@ private fun ArtistHeaderActions( ) } } else { - MillenniumButton(onClick = onFollowClick) { + MillenniumButton(onClick = onFollowClick, loading = isSaving) { Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } @@ -554,7 +563,9 @@ private fun ArtistHeaderActions( @Composable private fun TopTracksHeader( onPlay: () -> Unit, - onAddToQueue: () -> Unit + onAddToQueue: () -> Unit, + isPlayLoading: Boolean = false, + isAddToQueueLoading: Boolean = false ) { Row( modifier = Modifier @@ -572,6 +583,7 @@ private fun TopTracksHeader( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumIconButton( onClick = onPlay, + loading = isPlayLoading, style = MillenniumIconButtonStyle.Primary ) { MillenniumIcon( @@ -582,6 +594,7 @@ private fun TopTracksHeader( } MillenniumIconButton( onClick = onAddToQueue, + loading = isAddToQueueLoading, style = MillenniumIconButtonStyle.Secondary ) { MillenniumIcon( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt index 9f915280..81764bab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt @@ -27,6 +27,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -44,6 +45,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString @@ -61,7 +63,8 @@ sealed interface ArtistScreenState { val relatedArtists: List, val relatedArtistsNextPagination: PaginationStrategy?, val featuredPlaylists: List, - val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState + val featuredPlaylistsNextPagination: PaginationStrategy?, + val isSaving: Boolean = false) : ArtistScreenState data class Error(val message: String) : ArtistScreenState } @@ -82,6 +85,11 @@ class ArtistViewModel( private val _state = MutableStateFlow(ArtistScreenState.Loading) val state: StateFlow = _state.asStateFlow() + /** Loading flags for the top-tracks play / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.ArtistTopTracks, artistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) + val savedArtistIds get() = libraryRepository.savedArtistIdsFlow @@ -152,13 +160,26 @@ class ArtistViewModel( } fun toggleSavedArtist() { + if ((_state.value as? ArtistScreenState.Loaded)?.isSaving == true) return + setSaving(true) viewModelScope.launch { - val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false - if (isLiked) { - libraryRepository.removeSavedArtists(listOf(artistId)) - } else { - libraryRepository.saveArtists(listOf(artistId)) + runCatching { + val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedArtists(listOf(artistId)) + } else { + libraryRepository.saveArtists(listOf(artistId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved artist" } } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + if (state is ArtistScreenState.Loaded) state.copy(isSaving = isSaving) else state } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt index 58067ff9..e9d946d1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -122,22 +122,16 @@ fun DevicesScreen( backButton = true, title = { Text(stringResource(Res.string.devices_title)) }, actions = { - if (state.isDiscovering) { - MillenniumSpokeSpinner( - size = 20.dp, - modifier = Modifier.padding(horizontal = 10.dp) + MillenniumIconButton( + onClick = viewModel::startDiscovery, + enabled = !state.isConnecting, + loading = state.isDiscovering, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.devices_refresh) ) - } else { - MillenniumIconButton( - onClick = viewModel::startDiscovery, - enabled = !state.isConnecting, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = stringResource(Res.string.devices_refresh) - ) - } } } ) @@ -302,6 +296,7 @@ private fun LazyItemScope.ConnectedDeviceCard( ) { MillenniumButton( onClick = onDisconnect, + loading = device.isDisconnecting, style = MillenniumButtonStyle.Secondary ) { Text(stringResource(Res.string.devices_disconnect)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt index e9c24fa4..19f365ee 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -66,7 +66,8 @@ data class DeviceItemUi( data class ConnectedDeviceUi( val name: String?, - val address: String) + val address: String, + val isDisconnecting: Boolean = false) data class DevicesUiState( val devices: List = emptyList(), @@ -89,14 +90,15 @@ class DevicesViewModel( private val _isDiscovering = MutableStateFlow(false) private val _connectingToDevice = MutableStateFlow(null) private val _error = MutableStateFlow(null) + private val _isDisconnecting = MutableStateFlow(false) val uiState: StateFlow = combine( _devices, _isDiscovering, _connectingToDevice, remoteControlClient.connectionState, - _error - ) { devices, isDiscovering, connectingTo, connection, error -> + combine(_error, _isDisconnecting, ::Pair) + ) { devices, isDiscovering, connectingTo, connection, (error, isDisconnecting) -> val connected = connection as? ConnectionState.Connected DevicesUiState( devices = devices.values.map { device -> @@ -112,7 +114,8 @@ class DevicesViewModel( connectedDevice = connected?.let { ConnectedDeviceUi( name = it.deviceName.ifBlank { null }, - address = "${it.host}:${it.port}") + address = "${it.host}:${it.port}", + isDisconnecting = isDisconnecting) }, error = error) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) @@ -288,8 +291,14 @@ class DevicesViewModel( } fun disconnect() { + if (_isDisconnecting.value) return + _isDisconnecting.value = true viewModelScope.launch { - remoteControlClient.disconnect() + try { + remoteControlClient.disconnect() + } finally { + _isDisconnecting.value = false + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt index 3ae9a7a2..568a018b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt @@ -203,12 +203,12 @@ internal fun SetupPanel( MillenniumButton( onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin, enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin, + loading = state.isBusy, modifier = Modifier.fillMaxWidth() ) { Text( stringResource( when { - state.isBusy -> Res.string.jam_setup_working state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button else -> Res.string.jam_setup_join_button } @@ -451,6 +451,7 @@ internal fun SessionPanel( } MillenniumButton( onClick = onLeave, + loading = state.isLeaving, style = MillenniumButtonStyle.Secondary, modifier = Modifier.align(Alignment.End) ) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt index 2d89c67d..5041e371 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -65,6 +65,7 @@ data class JamUiState( val canCreate: Boolean = false, val canJoin: Boolean = false, val isBusy: Boolean = false, + val isLeaving: Boolean = false, val isHost: Boolean = false, val supportsNativeShare: Boolean = false, val codeCopied: Boolean = false @@ -79,6 +80,7 @@ class JamViewModel( private val _localError = MutableStateFlow(null) private val setupMode = MutableStateFlow(JamSetupMode.Create) private val isBusy = MutableStateFlow(false) + private val isLeaving = MutableStateFlow(false) private val codeCopied = MutableStateFlow(false) private val codeInput = TextFieldState() private var copyFeedbackJob: Job? = null @@ -99,7 +101,8 @@ class JamViewModel( isBusy, codeCopied, snapshotFlow { codeInput.text.toString() }, - participantMenu + participantMenu, + isLeaving ) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? @@ -134,10 +137,11 @@ class JamViewModel( error = localError ?: connectionError, setupMode = values[7] as JamSetupMode, isBusy = busy, + isLeaving = values[12] as Boolean, codeCopied = values[9] as Boolean, codeInput = codeInput, - canCreate = brokerConfigured && !busy, - canJoin = brokerConfigured && !busy && + canCreate = brokerConfigured, + canJoin = brokerConfigured && JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), isHost = role == JamRole.Host, supportsNativeShare = supportsNativeShare @@ -216,9 +220,15 @@ class JamViewModel( } fun leave() { + if (isLeaving.value) return + isLeaving.value = true viewModelScope.launch { - jamRoomService.leaveRoom() - _localError.value = null + try { + jamRoomService.leaveRoom() + _localError.value = null + } finally { + isLeaving.value = false + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt index f7b7bfc0..144286a0 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt @@ -120,7 +120,6 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryPlaylistsScreen( showCreatePlaylist = state.showCreatePlaylist, - onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, viewMode = viewMode ) } @@ -228,6 +227,7 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxRefreshRight, contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), onClick = onRefresh, + loading = state.isRefreshing, modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt index aa703732..cb85e965 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt @@ -42,6 +42,7 @@ data class LibraryToolbarUiState( val canFilter: Boolean = true, val canCreatePlaylist: Boolean = true, val canRefresh: Boolean = false, + val isRefreshing: Boolean = false, val showCreatePlaylist: Boolean = false, val canChangeView: Boolean = true, val viewModeOverride: LibraryViewMode? = null @@ -57,13 +58,15 @@ class LibraryState : ViewModel() { private val viewModes = MutableStateFlow>(emptyMap()) private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) val localRefreshRequests = refreshRequests.asSharedFlow() + private val localRefreshing = MutableStateFlow(false) val uiState = combine( currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, searchMode, createPlaylistVisible, - viewModes - ) { (tab, query), searching, creating, modes -> + viewModes, + localRefreshing + ) { (tab, query), searching, creating, modes, refreshing -> LibraryToolbarUiState( currentTab = tab, searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), @@ -71,6 +74,7 @@ class LibraryState : ViewModel() { canFilter = tab != LibraryTab.Downloads, canCreatePlaylist = tab == LibraryTab.Playlists, canRefresh = tab == LibraryTab.LocalTracks, + isRefreshing = tab == LibraryTab.LocalTracks && refreshing, showCreatePlaylist = tab == LibraryTab.Playlists && creating, canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, viewModeOverride = modes[tab] @@ -113,6 +117,11 @@ class LibraryState : ViewModel() { refreshRequests.tryEmit(Unit) } + /** Reported by the local tracks ViewModel so the toolbar's refresh action can show progress. */ + fun setLocalRefreshing(refreshing: Boolean) { + localRefreshing.value = refreshing + } + fun toggleViewMode(isCompact: Boolean) { val tab = currentTab.value if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index b28db0c6..3fd0c16c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException import org.spotube.spotube.PlatformType import org.spotube.spotube.getPlatform import org.jetbrains.compose.resources.getString @@ -160,19 +161,25 @@ class LibraryLocalTracksViewModel( } fun refresh() { - if (!_state.value.canAccessMedia) return + if (!_state.value.canAccessMedia || _state.value.isRefreshing) return + _state.update { it.copy(isRefreshing = true, error = null) } + libraryState.setLocalRefreshing(true) viewModelScope.launch { - _state.update { it.copy(isRefreshing = true, error = null) } - runCatching { - coordinator.refreshNow("manual") - }.onFailure { throwable -> - val message = - throwable.message ?: getString(Res.string.library_local_refresh_failed) - _state.update { - it.copy(error = message) + try { + runCatching { + coordinator.refreshNow("manual") + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + val message = + throwable.message ?: getString(Res.string.library_local_refresh_failed) + _state.update { + it.copy(error = message) + } } + } finally { + _state.update { it.copy(isRefreshing = false) } + libraryState.setLocalRefreshing(false) } - _state.update { it.copy(isRefreshing = false) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index cb652364..85a7f52a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -28,11 +28,11 @@ import spotube.composeapp.generated.resources.* fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, showCreatePlaylist: Boolean = false, - onDismissCreatePlaylist: () -> Unit = {}, viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryPlaylistsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle() Column(modifier) { when (val current = state) { is LibraryPlaylistsState.Error -> Box( @@ -87,8 +87,10 @@ fun LibraryPlaylistsScreen( } PlaylistFormSheet( visible = showCreatePlaylist, - onDismiss = onDismissCreatePlaylist, - onSubmit = viewModel::createPlaylist + formState = playlistFormState, + onDismiss = viewModel::dismissCreatePlaylist, + onSubmit = viewModel::createPlaylist, + onImagePicked = viewModel::onPlaylistFormImagePicked ) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 159fbd04..2503abf9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import io.github.vinceglb.filekit.PlatformFile import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res @@ -159,10 +160,31 @@ class LibraryPlaylistsViewModel( } } - fun createPlaylist(data: PlaylistFormData) = createPlaylist( - data.name, data.description.ifBlank { null }, data.isPublic, - data.isCollaborating, data.imageBase64, emptyList() - ) + private val playlistForm = PlaylistFormController(viewModelScope, logger) + val playlistFormState: StateFlow = playlistForm.uiState + + fun onPlaylistFormImagePicked(file: PlatformFile) = playlistForm.onImagePicked(file) + + fun dismissCreatePlaylist() { + if (playlistFormState.value.isSubmitting) return + playlistForm.dismiss() + libraryState.dismissCreatePlaylist() + } + + fun createPlaylist(data: PlaylistFormData) { + playlistForm.submit(onSuccess = libraryState::dismissCreatePlaylist) { + repository.createPlaylist( + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64, + trackIds = emptyList() + ) + repository.invalidateCaches() + loadInitialData() + } + } fun refresh() { viewModelScope.launch { @@ -170,31 +192,4 @@ class LibraryPlaylistsViewModel( loadInitialData() } } - - fun createPlaylist( - name: String, - description: String?, - isPublic: Boolean, - isCollaborating: Boolean, - imageBase64: String, - trackIds: List - ) { - viewModelScope.launch { - runCatching { - repository.createPlaylist( - name = name, - description = description, - isPublic = isPublic, - isCollaborating = isCollaborating, - imageBase64 = imageBase64, - trackIds = trackIds - ) - }.onFailure { e -> - logger.e(e) { "Failed to create playlist" } - }.onSuccess { - repository.invalidateCaches() - loadInitialData() - } - } - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt new file mode 100644 index 00000000..ed60c201 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt @@ -0,0 +1,96 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library.playlist + +import co.touchlab.kermit.Logger +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString + +data class PlaylistFormUiState( + val isVisible: Boolean = false, + val isSubmitting: Boolean = false, + val isProcessingImage: Boolean = false, + /** Base64 of an image picked in this session; `null` keeps the playlist's current cover. */ + val pickedImageBase64: String? = null +) + +/** + * Async state of [PlaylistFormSheet] (visibility, image encoding, submission), owned by the + * ViewModel that opens the sheet and driven on its [scope]. + */ +class PlaylistFormController( + private val scope: CoroutineScope, + private val logger: Logger +) { + private val _uiState = MutableStateFlow(PlaylistFormUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun show() { + _uiState.value = PlaylistFormUiState(isVisible = true) + } + + fun dismiss() { + if (_uiState.value.isSubmitting) return + _uiState.value = PlaylistFormUiState() + } + + fun onImagePicked(file: PlatformFile) { + if (_uiState.value.isProcessingImage) return + _uiState.update { it.copy(isProcessingImage = true) } + scope.launch { + runCatching { + withContext(Dispatchers.Default) { file.readBytes().toByteString().base64() } + }.onSuccess { base64 -> + _uiState.update { it.copy(pickedImageBase64 = base64) } + }.onFailure { e -> + logger.e(e) { "Failed to read playlist cover image" } + } + _uiState.update { it.copy(isProcessingImage = false) } + } + } + + /** + * Runs [action]; the sheet closes (and [onSuccess] runs) only if it succeeds, and stays open + * (re-enabled) on failure. + */ + fun submit(onSuccess: () -> Unit = {}, action: suspend () -> Unit) { + val current = _uiState.value + if (current.isSubmitting || current.isProcessingImage) return + _uiState.update { it.copy(isSubmitting = true) } + scope.launch { + runCatching { action() } + .onSuccess { + _uiState.value = PlaylistFormUiState() + onSuccess() + } + .onFailure { e -> + logger.e(e) { "Failed to submit playlist form" } + _uiState.update { it.copy(isSubmitting = false) } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt index 47e82f01..7ef9e130 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -34,9 +34,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent @@ -51,7 +49,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -62,12 +59,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher -import io.github.vinceglb.filekit.readBytes -import kotlinx.coroutines.Dispatchers +import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okio.ByteString.Companion.toByteString import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.library_playlist_form_change_image @@ -101,8 +94,10 @@ data class PlaylistFormData( @Composable fun PlaylistFormSheet( visible: Boolean, + formState: PlaylistFormUiState, onDismiss: () -> Unit, onSubmit: (PlaylistFormData) -> Unit, + onImagePicked: (PlatformFile) -> Unit, modifier: Modifier = Modifier, initialData: PlaylistFormData? = null, isEditing: Boolean = false @@ -119,8 +114,8 @@ fun PlaylistFormSheet( initialData?.isCollaborating ?: false ) } - var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } - var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } + val imageBase64 = formState.pickedImageBase64 ?: initialData?.imageBase64.orEmpty() + val imagePreviewUrl = if (formState.pickedImageBase64 != null) null else initialData?.imagePreviewUrl val nameState = rememberTextFieldState(initialData?.name ?: "") val descriptionState = rememberTextFieldState(initialData?.description ?: "") @@ -136,20 +131,10 @@ fun PlaylistFormSheet( .collect { description = it } } - val scope = rememberCoroutineScope() val imagePicker = rememberFilePickerLauncher( type = FileKitType.Image ) { file -> - if (file != null) { - scope.launch { - val bytes = file.readBytes() - val base64 = withContext(Dispatchers.Default) { - bytes.toByteString().base64() - } - imageBase64 = base64 - imagePreviewUrl = null - } - } + if (file != null) onImagePicked(file) } val currentData = PlaylistFormData( @@ -176,15 +161,14 @@ fun PlaylistFormSheet( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { MillenniumButton( onClick = onDismiss, + enabled = !formState.isSubmitting, style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_cancel)) } MillenniumButton( - onClick = { - onSubmit(currentData) - onDismiss() - }, - enabled = name.isNotBlank()) { + onClick = { onSubmit(currentData) }, + enabled = name.isNotBlank() && !formState.isProcessingImage, + loading = formState.isSubmitting) { Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) } } @@ -198,6 +182,8 @@ fun PlaylistFormSheet( onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, + isProcessingImage = formState.isProcessingImage, + isImagePickEnabled = !formState.isSubmitting, onPickImage = handleImagePick) } } @@ -212,12 +198,14 @@ private fun PlaylistFormFields( onIsCollaboratingChange: (Boolean) -> Unit, imageBase64: String, imagePreviewUrl: String?, + isProcessingImage: Boolean, + isImagePickEnabled: Boolean, onPickImage: () -> Unit ) { + // AdaptiveDialogBottomSheet already scrolls its content; a second scroll here nests + // scrollables and crashes with infinite height constraints. Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { MillenniumTextField( @@ -312,7 +300,10 @@ private fun PlaylistFormFields( } } } - MillenniumButton(onClick = onPickImage) { + MillenniumButton( + onClick = onPickImage, + enabled = isImagePickEnabled, + loading = isProcessingImage) { Text(stringResource(if (imageBase64.isNotBlank() || imagePreviewUrl != null) Res.string.library_playlist_form_change_image else Res.string.library_playlist_form_pick_image)) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index c5f10295..2f785cd8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -20,7 +20,6 @@ package org.spotube.spotube.modules.playlist import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.millennium.composables.MillenniumCircularProgress -import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle @@ -48,15 +47,8 @@ import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -65,16 +57,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import org.spotube.spotube.modules.library.LibraryRepository -import org.spotube.spotube.modules.search.SearchRepository import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch import org.spotube.spotube.resources.iconsax.IconsaxSearch -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch -import org.koin.compose.koinInject import org.jetbrains.compose.resources.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.playlist_add_tracks_add import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description @@ -90,59 +80,17 @@ fun AddTracksToPlaylistDialog( visible: Boolean, playlistId: String, onDismiss: () -> Unit, - modifier: Modifier = Modifier, - searchRepository: SearchRepository = koinInject(), - libraryRepository: LibraryRepository = koinInject()) { + modifier: Modifier = Modifier) { if (!visible) return - val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium - - var query by remember { mutableStateOf("") } - var searchResults by remember { mutableStateOf>(emptyList()) } - var isSearching by remember { mutableStateOf(false) } - var hasSearched by remember { mutableStateOf(false) } - var addingTrackIds by remember { mutableStateOf>(emptySet()) } - var addedTrackIds by remember { mutableStateOf>(emptySet()) } - val scope = rememberCoroutineScope() - val queryState = rememberTextFieldState(query) - - LaunchedEffect(queryState) { - snapshotFlow { queryState.text.toString() } - .drop(1) - .collect { query = it } - } - - LaunchedEffect(visible) { - if (!visible) { - query = "" - searchResults = emptyList() - hasSearched = false - addingTrackIds = emptySet() - addedTrackIds = emptySet() - } - } - - val handleSearch = { - if (query.isNotBlank() && !isSearching) { - isSearching = true - hasSearched = true - scope.launch { - val result = searchRepository.searchTracks(query) - searchResults = result.items.map { it.data } - isSearching = false - } - } - } - - val handleAddTrack = { track: MetadataTrack -> - if (!addingTrackIds.contains(track.id) && !addedTrackIds.contains(track.id)) { - addingTrackIds = addingTrackIds + track.id - scope.launch { - libraryRepository.addTracksToPlaylist(playlistId, listOf(track.id)) - addingTrackIds = addingTrackIds - track.id - addedTrackIds = addedTrackIds + track.id - } - } + val viewModel: AddTracksToPlaylistViewModel = koinViewModel( + key = "add-tracks-$playlistId", + parameters = { parametersOf(playlistId) } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val dismiss = { + viewModel.reset() + onDismiss() } val content: @Composable () -> Unit = { @@ -154,18 +102,19 @@ fun AddTracksToPlaylistDialog( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { MillenniumTextField( - state = queryState, + state = state.queryState, modifier = Modifier.weight(1f), placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder)) MillenniumIconButton( icon = Iconsax.IconsaxSearch, contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description), - onClick = handleSearch, + onClick = viewModel::search, + loading = state.isSearching, style = MillenniumIconButtonStyle.Secondary) } when { - isSearching -> { + state.isSearching -> { Box( modifier = Modifier .fillMaxWidth() @@ -174,7 +123,7 @@ fun AddTracksToPlaylistDialog( MillenniumCircularProgress(size = 40.dp) } } - hasSearched && searchResults.isEmpty() -> { + state.hasSearched && state.results.isEmpty() -> { Box( modifier = Modifier .fillMaxWidth() @@ -183,18 +132,18 @@ fun AddTracksToPlaylistDialog( Text(stringResource(Res.string.playlist_add_tracks_no_results)) } } - searchResults.isNotEmpty() -> { + state.results.isNotEmpty() -> { LazyColumn( modifier = Modifier .fillMaxWidth() .weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(searchResults, key = { it.id }) { track -> + items(state.results, key = { it.track.id }) { result -> TrackSearchResultRow( - track = track, - isAdding = addingTrackIds.contains(track.id), - isAdded = addedTrackIds.contains(track.id), - onAdd = { handleAddTrack(track) }) + track = result.track, + isAdding = result.isAdding, + isAdded = result.isAdded, + onAdd = { viewModel.addTrack(result.track.id) }) } } } @@ -213,7 +162,7 @@ fun AddTracksToPlaylistDialog( val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) AdaptiveDialogBottomSheet( - onDismiss = onDismiss, + onDismiss = dismiss, modifier = modifier, dialogTitle = dialogTitle, title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, @@ -221,7 +170,7 @@ fun AddTracksToPlaylistDialog( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - MillenniumButton(onClick = onDismiss) { + MillenniumButton(onClick = dismiss) { Text(stringResource(Res.string.playlist_add_tracks_done)) } } @@ -288,12 +237,10 @@ private fun TrackSearchResultRow( tint = AppTheme.colors.primary, modifier = Modifier.size(24.dp)) } - isAdding -> { - MillenniumSpokeSpinner(size = 24.dp) - } else -> { MillenniumButton( onClick = onAdd, + loading = isAdding, style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.playlist_add_tracks_add)) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt new file mode 100644 index 00000000..c120b64b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt @@ -0,0 +1,131 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.playlist + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.search.SearchRepository +import kotlin.coroutines.cancellation.CancellationException + +data class AddTrackResultUi( + val track: MetadataTrack, + val isAdding: Boolean, + val isAdded: Boolean +) + +data class AddTracksToPlaylistUiState( + val queryState: TextFieldState = TextFieldState(), + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false +) + +private data class AddTracksState( + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false, + val addingTrackIds: Set = emptySet(), + val addedTrackIds: Set = emptySet() +) + +class AddTracksToPlaylistViewModel( + private val playlistId: String, + private val searchRepository: SearchRepository, + private val libraryRepository: LibraryRepository +) : ViewModel() { + private val logger = Logger.withTag("AddTracksToPlaylistViewModel") + private val queryState = TextFieldState() + private val state = MutableStateFlow(AddTracksState()) + private var searchJob: Job? = null + + val uiState: StateFlow = state.map { current -> + AddTracksToPlaylistUiState( + queryState = queryState, + results = current.results.map { track -> + AddTrackResultUi( + track = track, + isAdding = track.id in current.addingTrackIds, + isAdded = track.id in current.addedTrackIds + ) + }, + isSearching = current.isSearching, + hasSearched = current.hasSearched + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + AddTracksToPlaylistUiState(queryState = queryState) + ) + + fun search() { + val query = queryState.text.toString() + if (query.isBlank() || state.value.isSearching) return + state.update { it.copy(isSearching = true, hasSearched = true) } + searchJob = viewModelScope.launch { + try { + val results = searchRepository.searchTracks(query).items.map { it.data } + state.update { it.copy(results = results) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to search tracks for \"$query\"" } + state.update { it.copy(results = emptyList()) } + } finally { + state.update { it.copy(isSearching = false) } + } + } + } + + fun addTrack(trackId: String) { + val current = state.value + if (trackId in current.addingTrackIds || trackId in current.addedTrackIds) return + state.update { it.copy(addingTrackIds = it.addingTrackIds + trackId) } + viewModelScope.launch { + try { + libraryRepository.addTracksToPlaylist(playlistId, listOf(trackId)) + state.update { it.copy(addedTrackIds = it.addedTrackIds + trackId) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to add track $trackId to playlist $playlistId" } + } finally { + state.update { it.copy(addingTrackIds = it.addingTrackIds - trackId) } + } + } + } + + /** Clears the search so the dialog opens fresh next time; in-flight adds still complete. */ + fun reset() { + searchJob?.cancel() + queryState.clearText() + state.update { AddTracksState() } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt index ececcd31..370c8ca6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt @@ -66,6 +66,7 @@ fun PlaylistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -77,7 +78,7 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - var showEditPlaylist by remember { mutableStateOf(false) } + val editPlaylistFormState by viewModel.editPlaylistFormState.collectAsStateWithLifecycle() var showAddTracksDialog by remember { mutableStateOf(false) } val dataState = state as? PlaylistScreenState.Data @@ -117,6 +118,9 @@ fun PlaylistScreen( } else null CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, title = playlist?.title ?: stringResource(Res.string.playlist_loading_title), description = playlist?.description.orEmpty(), imageURL = artworkUrl, @@ -128,9 +132,10 @@ fun PlaylistScreen( onAddToQueue = viewModel::addPlaylistToQueue, isPlaying = isPlaying, isFollowing = savedPlaylistIds.contains(playlistId), + isFollowLoading = dataState?.isSaving == true, onFollowClick = viewModel::toggleSavedPlaylist, showFollowButton = playlistId != "saved_tracks", - onEdit = if (isOwner) { { showEditPlaylist = true } } else null, + onEdit = if (isOwner) viewModel::showEditPlaylist else null, isLoading = isLoading, error = errorMessage, onRetry = { viewModel.refresh() }, @@ -157,8 +162,10 @@ fun PlaylistScreen( trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist PlaylistFormSheet( - visible = showEditPlaylist, - onDismiss = { showEditPlaylist = false }, + visible = editPlaylistFormState.isVisible, + formState = editPlaylistFormState, + onDismiss = viewModel::dismissEditPlaylist, + onImagePicked = viewModel::onEditPlaylistImagePicked, isEditing = true, initialData = loadedPlaylist?.let { PlaylistFormData( @@ -168,14 +175,7 @@ fun PlaylistScreen( isCollaborating = false, imagePreviewUrl = it.thumbnails.firstOrNull()?.url) }, - onSubmit = { data -> - viewModel.updatePlaylist( - name = data.name, - description = data.description.ifBlank { null }, - isPublic = data.isPublic, - isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64.ifBlank { null }) - }) + onSubmit = viewModel::updatePlaylist) AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt index af4d5686..241605c6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -48,6 +49,11 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import io.github.vinceglb.filekit.PlatformFile +import org.spotube.spotube.modules.library.playlist.PlaylistFormController +import org.spotube.spotube.modules.library.playlist.PlaylistFormData +import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString @@ -105,6 +111,11 @@ class PlaylistViewModel( private val _state = MutableStateFlow(PlaylistScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Playlist, playlistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedPlaylistIds get() = libraryRepository.savedPlaylistIdsFlow @@ -206,6 +217,8 @@ class PlaylistViewModel( } fun toggleSavedPlaylist() { + if ((_state.value as? PlaylistScreenState.Data)?.isSaving == true) return + setSaving(true) viewModelScope.launch { runCatching { val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false @@ -217,6 +230,17 @@ class PlaylistViewModel( }.onFailure { e -> logger.e(e) { "Failed to toggle saved playlist" } } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is PlaylistScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is PlaylistScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state + } } } @@ -256,28 +280,27 @@ class PlaylistViewModel( } } - fun updatePlaylist( - name: String?, - description: String?, - isPublic: Boolean?, - isCollaborating: Boolean?, - imageBase64: String?) { - viewModelScope.launch { - runCatching { - libraryRepository.updatePlaylist( - id = playlistId, - name = name, - description = description, - isPublic = isPublic, - isCollaborating = isCollaborating, - imageBase64 = imageBase64, - trackIds = null) - }.onFailure { e -> - logger.e(e) { "Failed to update playlist" } - }.onSuccess { - repository.invalidateCaches() - loadInitialData() - } + private val editPlaylistForm = PlaylistFormController(viewModelScope, logger) + val editPlaylistFormState: StateFlow = editPlaylistForm.uiState + + fun showEditPlaylist() = editPlaylistForm.show() + + fun dismissEditPlaylist() = editPlaylistForm.dismiss() + + fun onEditPlaylistImagePicked(file: PlatformFile) = editPlaylistForm.onImagePicked(file) + + fun updatePlaylist(data: PlaylistFormData) { + editPlaylistForm.submit { + libraryRepository.updatePlaylist( + id = playlistId, + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64.ifBlank { null }, + trackIds = null) + repository.invalidateCaches() + loadInitialData() } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt index 0350ba14..051ec896 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt @@ -23,6 +23,7 @@ import org.spotube.spotube.core.di.injectLogger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -30,12 +31,16 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent data class MetadataAuthUiState( /** True when the selected metadata plugin needs an account and the user isn't signed in. */ val loginRequired: Boolean = false, - val pluginName: String? = null) + val pluginName: String? = null, + /** True while a login started from the prompt is still running. */ + val isLoggingIn: Boolean = false) /** * Tells screens whether the selected metadata plugin is waiting for a login, so they can show a @@ -45,15 +50,17 @@ data class MetadataAuthUiState( class MetadataAuthViewModel( private val pluginManager: PluginManager) : ViewModel(), KoinComponent { private val logger by injectLogger() + private val isLoggingIn = MutableStateFlow(false) val uiState: StateFlow = combine( pluginManager.selectedMetadataPlugin.flatMapLatest { service -> if (service == null) flowOf(false) else loginRequiredFlow(service) }, - pluginManager.state) { loginRequired, managerState -> + pluginManager.state, + isLoggingIn) { loginRequired, managerState, loggingIn -> val name = (managerState as? PluginManagerStates.Data) ?.selectedPlugins?.get(PluginAbility.METADATA)?.name - MetadataAuthUiState(loginRequired = loginRequired, pluginName = name) + MetadataAuthUiState(loginRequired = loginRequired, pluginName = name, isLoggingIn = loggingIn) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow = flow { @@ -71,10 +78,20 @@ class MetadataAuthViewModel( } fun login() { + if (isLoggingIn.value) return val service = pluginManager.selectedMetadataPlugin.value ?: return - pluginManager.launchTask { + // The login runs in the plugin manager's scope so it survives this screen; we only wait on it. + val task = pluginManager.launchTask { runCatching { service.use { coreAPI.login() } } .onFailure { logger.e(it) { "Metadata plugin login failed" } } } + isLoggingIn.value = true + viewModelScope.launch { + try { + task.join() + } finally { + isLoggingIn.update { false } + } + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 81f1ae84..71871b23 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -390,22 +390,30 @@ class PluginManager( } } - /** Called when the user confirms installation/update/replacement in the dialog. */ - fun confirmInstall() { - val pending = pendingPlugin.value ?: return - val bytes = pending.bytes ?: return dismissInstall() + /** + * Called when the user confirms installation/update/replacement in the dialog. The prompt stays + * up until the install finishes, so the dialog can show progress. + * + * @return The install job, or `null` when there was nothing to install. + */ + fun confirmInstall(): Job? { + val pending = pendingPlugin.value ?: return null + val bytes = pending.bytes ?: run { dismissInstall(); return null } val allowReplacingInstalled = when (pending.kind) { InstallPromptKind.INSTALL -> false InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true - InstallPromptKind.INFO -> return dismissInstall() - InstallPromptKind.REPLACE -> true + InstallPromptKind.INFO -> run { dismissInstall(); return null } } - pendingPlugin.value = null - scope.launch { - addPluginFromByteArray( - bytes = bytes, - allowReplacingInstalled = allowReplacingInstalled - ) + return scope.launch { + try { + addPluginFromByteArray( + bytes = bytes, + allowReplacingInstalled = allowReplacingInstalled + ) + } finally { + // Only close our own prompt; another plugin may have been queued meanwhile. + pendingPlugin.compareAndSet(pending, null) + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt index d5542e1f..0d52c279 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -235,6 +235,7 @@ internal fun PluginScreenContent( } else { null }, + isConfirming = state.isConfirmingInstall, onDismiss = { viewModel.dismissInstall() } ) } @@ -274,6 +275,7 @@ internal fun PluginScreenContent( urlInput = state.urlInput, urlError = state.urlError, isLoadingUrl = state.isLoadingUrl, + isPreparingFile = state.isPreparingFile, onUrlChange = { viewModel.onUrlInputChange(it) }, onSubmitUrl = { viewModel.submitUrl() }, onPickFile = { launcher.launch() }, @@ -459,6 +461,8 @@ internal fun PluginScreenContent( onRemove = { viewModel.removePlugin(item.plugin) }, isLoggedIn = item.authState.isLoggedIn, logoPath = item.logoPath, + isAuthInProgress = item.isAuthInProgress, + isRemoving = item.isRemoving, onInfo = { viewModel.showPluginInfo(item.plugin) }, onSupport = if (item.isSelected) { { viewModel.loadSupport(item.plugin) } @@ -620,6 +624,7 @@ private fun InstallSheet( urlInput: String, urlError: UrlError?, isLoadingUrl: Boolean, + isPreparingFile: Boolean, onUrlChange: (String) -> Unit, onSubmitUrl: () -> Unit, onPickFile: () -> Unit, @@ -671,20 +676,16 @@ private fun InstallSheet( error = urlError?.text()) MillenniumButton( onClick = onSubmitUrl, - enabled = !isLoadingUrl, + loading = isLoadingUrl, modifier = Modifier.fillMaxWidth(), style = MillenniumButtonStyle.Primary) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (isLoadingUrl) { - MillenniumSpokeSpinner(size = 16.dp, color = MillenniumTheme.colors.onAccent) - } else { - MillenniumIcon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = null, - modifier = Modifier.size(18.dp)) - } + MillenniumIcon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = null, + modifier = Modifier.size(18.dp)) Text(stringResource(Res.string.plugin_action_download)) } } @@ -703,7 +704,7 @@ private fun InstallSheet( MillenniumSeparator(modifier = Modifier.weight(1f)) } - PluginPanel(modifier = Modifier.clickable(onClick = onPickFile)) { + PluginPanel(modifier = Modifier.clickable(enabled = !isPreparingFile, onClick = onPickFile)) { Row( modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically, @@ -721,11 +722,15 @@ private fun InstallSheet( style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant) } - MillenniumIcon( - FeatherIcons.ChevronRight, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = AppTheme.colors.onSurfaceVariant) + if (isPreparingFile) { + MillenniumSpokeSpinner(size = 18.dp) + } else { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) + } } } } @@ -1013,20 +1018,16 @@ private fun DiscoverRepoCard( } MillenniumButton( onClick = onInstall, - enabled = !isInstalling, + loading = isInstalling, style = MillenniumButtonStyle.Secondary ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - if (isInstalling) { - MillenniumSpokeSpinner(size = 16.dp) - } else { - MillenniumIcon( - Iconsax.IconsaxAdd, - contentDescription = null) - } + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = null) Text(stringResource(Res.string.plugin_section_install)) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt index 510d04e8..e3d5ff5c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -26,6 +26,7 @@ import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -55,7 +56,9 @@ data class PluginListItem( val plugin: PluginEntry, val isSelected: Boolean, val authState: PluginAuthState, - val logoPath: Path?) + val logoPath: Path?, + val isAuthInProgress: Boolean = false, + val isRemoving: Boolean = false) data class AbilitySelection( val ability: PluginAbility, @@ -92,6 +95,8 @@ sealed interface PluginUiState { val urlInput: String = "", val urlError: UrlError? = null, val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, val showInstallSheet: Boolean = false, val showPluginInfo: PluginEntry? = null, val pluginInfoLogoPath: Path? = null, @@ -118,6 +123,10 @@ private data class PluginScreenState( val urlInput: String = "", val urlError: UrlError? = null, val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, + val authInProgressIds: Set = emptySet(), + val removingIds: Set = emptySet(), val showInstallSheet: Boolean = false, val showPluginInfo: PluginEntry? = null, val pluginInfoLogoPath: Path? = null, @@ -235,7 +244,9 @@ class PluginViewModel( plugin = plugin, isSelected = state.selectedPlugins.containsValue(plugin), authState = authStates[plugin.id] ?: PluginAuthState(), - logoPath = logoPaths[plugin.id]) + logoPath = logoPaths[plugin.id], + isAuthInProgress = plugin.id in screen.authInProgressIds, + isRemoving = plugin.id in screen.removingIds) }, abilitySelections = listOf( AbilitySelection( @@ -263,6 +274,8 @@ class PluginViewModel( urlInput = screen.urlInput, urlError = screen.urlError, isLoadingUrl = screen.isLoadingUrl, + isPreparingFile = screen.isPreparingFile, + isConfirmingInstall = screen.isConfirmingInstall, showInstallSheet = screen.showInstallSheet, showPluginInfo = screen.showPluginInfo, pluginInfoLogoPath = screen.pluginInfoLogoPath, @@ -360,12 +373,18 @@ class PluginViewModel( } fun onFileSelected(file: PlatformFile?) { - if (file == null) return + if (file == null || _screenState.value.isPreparingFile) return + _screenState.update { it.copy(isPreparingFile = true) } viewModelScope.launch { - runCatching { - pluginManager.preparePlugin(file.readBytes()) - }.onFailure { e -> - logger.e(e) { "Failed to prepare plugin from file" } + try { + runCatching { + pluginManager.preparePlugin(file.readBytes()) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to prepare plugin from file" } + } + } finally { + _screenState.update { it.copy(isPreparingFile = false) } } } } @@ -456,34 +475,68 @@ class PluginViewModel( } fun removePlugin(plugin: PluginEntry) { + if (plugin.id in _screenState.value.removingIds) return + _screenState.update { it.copy(removingIds = it.removingIds + plugin.id) } viewModelScope.launch { - runCatching { - pluginManager.removePlugin(plugin) - }.onFailure { e -> - logger.e(e) { "Failed to remove plugin" } + try { + runCatching { + pluginManager.removePlugin(plugin) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to remove plugin" } + } + } finally { + _screenState.update { it.copy(removingIds = it.removingIds - plugin.id) } } } } fun login(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return val service = findServiceForPlugin(plugin.id) ?: return - pluginManager.launchTask { + trackAuthTask(plugin.id, pluginManager.launchTask { service.use { coreAPI.login() } - } + }) } fun logout(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return val service = findServiceForPlugin(plugin.id) ?: return - pluginManager.launchTask { + val task = pluginManager.launchTask { service.use { coreAPI.logout() } } - viewModelScope.launch { + trackAuthTask(plugin.id, task) { webViewController.clearData(plugin.id) } } + /** + * Marks [pluginId] busy until [task] (running in the plugin manager's scope, so it outlives this + * screen) and the optional [alongside] work finish, whatever their outcome. + */ + private fun trackAuthTask(pluginId: String, task: Job, alongside: suspend () -> Unit = {}) { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds + pluginId) } + viewModelScope.launch { + try { + alongside() + task.join() + } finally { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds - pluginId) } + } + } + } + fun confirmInstall() { - pluginManager.confirmInstall() + if (_screenState.value.isConfirmingInstall) return + val task = pluginManager.confirmInstall() ?: return + _screenState.update { it.copy(isConfirmingInstall = true) } + viewModelScope.launch { + try { + task.join() + } finally { + _screenState.update { it.copy(isConfirmingInstall = false) } + } + } } fun dismissInstall() { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt index 0828cced..4937e3bb 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt @@ -77,7 +77,9 @@ internal fun PluginCard( onLogout: (() -> Unit)? = null, onInfo: (() -> Unit)? = null, onSupport: (() -> Unit)? = null, - logoPath: Path? = null) { + logoPath: Path? = null, + isAuthInProgress: Boolean = false, + isRemoving: Boolean = false) { Row( modifier = Modifier .fillMaxWidth() @@ -272,6 +274,7 @@ internal fun PluginCard( } else { MillenniumIconButton( onClick = onRemove, + loading = isRemoving, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( Iconsax.IconsaxTrash, @@ -289,6 +292,7 @@ internal fun PluginCard( authAction?.let { (action, label) -> MillenniumButton( onClick = action, + loading = isAuthInProgress, style = MillenniumButtonStyle.Secondary) { Text( text = stringResource(label), diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt index c86c4e9d..26e390ef 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt @@ -226,7 +226,8 @@ internal fun PluginDialogActions( onSecondary: () -> Unit, primaryLabel: String? = null, onPrimary: (() -> Unit)? = null, - primaryEnabled: Boolean = true) { + primaryEnabled: Boolean = true, + primaryLoading: Boolean = false) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { @@ -240,6 +241,7 @@ internal fun PluginDialogActions( MillenniumButton( onClick = onPrimary, enabled = primaryEnabled, + loading = primaryLoading, modifier = Modifier.weight(1f), style = MillenniumButtonStyle.Primary) { Text(primaryLabel) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt index 750448e7..c919aaa4 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -72,6 +72,7 @@ fun PluginPermissionDialog( existingPlugin: PluginEntry? = null, logoPath: Path? = null, onConfirm: (() -> Unit)? = null, + isConfirming: Boolean = false, onDismiss: () -> Unit) { AdaptiveDialogBottomSheet( onDismiss = onDismiss, @@ -83,7 +84,8 @@ fun PluginPermissionDialog( if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), onSecondary = onDismiss, primaryLabel = confirmLabel, - onPrimary = onConfirm) + onPrimary = onConfirm, + primaryLoading = isConfirming) }) { PluginDialogBody { PluginPanel { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt index 5f37592d..a2dfde04 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -108,6 +108,7 @@ fun MetadataPluginGuard( LoginRequiredDisplay( pluginName = authState.pluginName, onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, modifier = Modifier.widthIn(max = 420.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt index b6bf6425..b3da0317 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -46,6 +46,7 @@ fun SavedTracksScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -65,6 +66,9 @@ fun SavedTracksScreen( val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, title = stringResource(Res.string.saved_tracks_title), description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "", imageURL = "", diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 2a947179..6ee5e398 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -34,6 +34,7 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.core.component.KoinComponent @@ -103,9 +105,18 @@ class SavedTracksViewModel( private val _state = MutableStateFlow(SavedTracksScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedTrackIdsFlow: StateFlow> get() = repository.savedTracksIdsFlow + // Tracks whose save/remove request is still in flight. + private val _pendingSavedTrackIds = MutableStateFlow>(emptySet()) + val pendingSavedTrackIds: StateFlow> = _pendingSavedTrackIds.asStateFlow() + private val _currentUserId = MutableStateFlow(null) val currentUserId: StateFlow = _currentUserId.asStateFlow() @@ -340,6 +351,23 @@ class SavedTracksViewModel( repository.removeSavedTracks(trackIds) } + fun toggleSavedTrack(trackId: String, isSaved: Boolean) { + if (trackId in _pendingSavedTrackIds.value) return + _pendingSavedTrackIds.update { it + trackId } + viewModelScope.launch { + runCatching { + if (isSaved) { + repository.removeSavedTracks(listOf(trackId)) + } else { + repository.saveTracks(listOf(trackId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved track" } + } + _pendingSavedTrackIds.update { it - trackId } + } + } + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id return title == other.title && diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt index d7810185..0643cb4b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt @@ -244,13 +244,9 @@ internal fun JamSettingsContent( trailingContent = { MillenniumButton( onClick = viewModel::testConnection, - enabled = canTest) { - Text( - if (uiState.isTesting) { - stringResource(Res.string.settings_jam_broker_testing) - } else { - stringResource(Res.string.settings_jam_broker_test) - }) + enabled = broker.host.isNotBlank(), + loading = uiState.isTesting) { + Text(stringResource(Res.string.settings_jam_broker_test)) } }, onClick = viewModel::testConnection) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt index 64a96ce2..61e58722 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt @@ -38,8 +38,12 @@ class JamSettingsViewModel( if (_uiState.value.isTesting) return _uiState.update { it.copy(isTesting = true, testResult = null) } viewModelScope.launch { - val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) - _uiState.update { it.copy(isTesting = false, testResult = result) } + try { + val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) + _uiState.update { it.copy(testResult = result) } + } finally { + _uiState.update { it.copy(isTesting = false) } + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt index 5368d6d0..3a0b58b8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt @@ -103,7 +103,7 @@ class LocalMediaFoldersViewModel( path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path }) }, isSaving = draft.isSaving, - canSave = draft.isOpen && !draft.isSaving && draft.paths != draft.original, + canSave = draft.isOpen && draft.paths != draft.original, saveFailed = draft.saveFailed, automaticNote = when { hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt index c6b61799..01795adf 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -188,8 +188,8 @@ internal fun LocalMediaFoldersEditorContent( ) { Text(stringResource(Res.string.common_cancel)) } - MillenniumButton(onClick = onSave, enabled = state.canSave) { - Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.common_save)) + MillenniumButton(onClick = onSave, enabled = state.canSave, loading = state.isSaving) { + Text(stringResource(Res.string.common_save)) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt index e20ce752..732a4d0a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.shape.CircleShape import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry @@ -36,7 +35,6 @@ import org.spotube.spotube.modules.saved_tracks.rememberIsSavedTracks import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxHeart import org.spotube.spotube.resources.iconsax.IconsaxHeart2 -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_like_content_description @@ -46,12 +44,12 @@ import spotube.composeapp.generated.resources.player_unlike_content_description fun PlayerHeartButton( audioPlayerQueue: AudioPlayerQueue, savedTracksViewModel: SavedTracksViewModel) { - val scope = rememberCoroutineScope() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val isSavedTrackState = rememberIsSavedTracks(trackIds = currentTrackId?.let { listOf(it) } ?: emptyList()) val savedTrackIds by savedTracksViewModel.savedTrackIdsFlow.collectAsStateWithLifecycle() + val pendingSavedTrackIds by savedTracksViewModel.pendingSavedTrackIds.collectAsStateWithLifecycle() val isInSavedIds = if (currentTrackId != null) currentTrackId in savedTrackIds else false val isLiked = if (isSavedTrackState is SavedState.Success) { @@ -60,21 +58,10 @@ fun PlayerHeartButton( isInSavedIds } - fun onLike() { - val entry = currentQueueEntry as? QueueEntry.StreamingTrack ?: return - if (isLiked) { - scope.launch { - savedTracksViewModel.removeSavedTracks(listOf(entry.track.id)) - } - } else { - scope.launch { - savedTracksViewModel.saveTracks(listOf(entry.track.id)) - } - } - } MillenniumIconButton( - onClick = ::onLike, + onClick = { currentTrackId?.let { savedTracksViewModel.toggleSavedTrack(it, isLiked) } }, enabled = isSavedTrackState is SavedState.Success, + loading = currentTrackId != null && currentTrackId in pendingSavedTrackIds, style = MillenniumIconButtonStyle.Secondary) { MillenniumIcon( imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3087d75..30e39bf7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" -millennium = "0.1.0" +millennium = "0.1.1" mqttClient = "2.1.1" mqttBuffer = "6.30.8" multiplatformMarkdownRenderer = "0.45.0" From 8ab9463c9a97217f6bc4daf318a0eae3f5b16dc3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 10:08:29 +0600 Subject: [PATCH 093/132] feat(packaging): Flathub, AUR, WinGet and Chocolatey publishing - Flatpak manifest (GNOME 51, libvlc built from source) installing from a new portable spotube-linux--.tar.xz, shared with the AUR package - Release workflow builds and attaches the Linux tarballs for x86_64 and aarch64 - Package files are JMustache templates rendered by :composeApp:renderPackaging with the version, release asset checksums and git-cliff release notes; the metainfo release history is generated - Publish workflow (workflow_dispatch, release tag input) renders once and pushes to Flathub, the AUR, WinGet (PR from team-spotube/winget-pkgs) and Chocolatey - Chocolatey and WinGet updated for the NSIS installer --- .github/workflows/spotube-publish-binary.yml | 274 ++++++++++++++++++ .github/workflows/spotube-release-binary.yml | 58 +++- AGENTS.md | 6 + buildSrc/build.gradle.kts | 9 +- buildSrc/settings.gradle.kts | 8 + .../org/spotube/gradle/GitCliffContextTask.kt | 92 ++++++ .../org/spotube/gradle/RenderPackagingTask.kt | 264 +++++++++++++++++ composeApp/build.gradle.kts | 33 +++ gradle/libs.versions.toml | 3 + packaging/aur/.SRCINFO | 19 -- packaging/aur/PKGBUILD | 40 --- packaging/aur/PKGBUILD.mustache | 32 ++ packaging/chocolatey/spotube.nuspec | 55 ---- packaging/chocolatey/spotube.nuspec.mustache | 48 +++ ...FICATION.txt => VERIFICATION.txt.mustache} | 8 +- .../chocolatey/tools/chocolateyinstall.ps1 | 14 +- .../chocolatey/tools/chocolateyuninstall.ps1 | 16 +- .../flatpak/org.spotube.spotube.yml.mustache | 112 +++++++ packaging/linux/org.spotube.spotube.desktop | 12 + .../org.spotube.spotube.metainfo.xml.mustache | 99 +++++++ packaging/package-linux-tarball.sh | 67 +++++ .../KRTirtho.Spotube.installer.yaml.mustache | 21 ++ ...RTirtho.Spotube.locale.en-US.yaml.mustache | 34 +++ .../winget/KRTirtho.Spotube.yaml.mustache | 7 + 24 files changed, 1199 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/spotube-publish-binary.yml create mode 100644 buildSrc/settings.gradle.kts create mode 100644 buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt create mode 100644 buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt delete mode 100644 packaging/aur/.SRCINFO delete mode 100644 packaging/aur/PKGBUILD create mode 100644 packaging/aur/PKGBUILD.mustache delete mode 100644 packaging/chocolatey/spotube.nuspec create mode 100644 packaging/chocolatey/spotube.nuspec.mustache rename packaging/chocolatey/tools/{VERIFICATION.txt => VERIFICATION.txt.mustache} (69%) create mode 100644 packaging/flatpak/org.spotube.spotube.yml.mustache create mode 100644 packaging/linux/org.spotube.spotube.desktop create mode 100644 packaging/linux/org.spotube.spotube.metainfo.xml.mustache create mode 100755 packaging/package-linux-tarball.sh create mode 100644 packaging/winget/KRTirtho.Spotube.installer.yaml.mustache create mode 100644 packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache create mode 100644 packaging/winget/KRTirtho.Spotube.yaml.mustache diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml new file mode 100644 index 00000000..0ce12c27 --- /dev/null +++ b/.github/workflows/spotube-publish-binary.yml @@ -0,0 +1,274 @@ +name: Publish + +# Publishes an existing GitHub release (made by spotube-release-binary.yml) to the package managers. +# The `render` job checks out the release tag, downloads its assets and renders packaging/ with +# Gradle (:composeApp:renderPackaging: version, checksums, git-cliff release notes). The publish +# jobs only push what it rendered. +# +# Secrets: +# FLATHUB_TOKEN PAT with push access to team-spotube/org.spotube.spotube +# AUR_USERNAME, AUR_EMAIL, AUR_SSH_PRIVATE_KEY AUR account that maintains spotube-bin +# WINGET_TOKEN classic PAT (public_repo) that can push to team-spotube/winget-pkgs and open PRs +# CHOCO_API_KEY chocolatey.org API key + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to publish (vX.Y.Z) + required: true + type: string + jobs: + description: Jobs to run (flathub,aur,winget,chocolatey) + required: true + type: string + default: flathub,aur,winget,chocolatey + aur_pkgrel: + description: AUR pkgrel (bump to re-release the same version) + required: true + type: string + default: '1' + dry_run: + description: Dry run (render the packages, push nothing) + required: true + type: boolean + default: true + +permissions: + contents: read + +env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + +jobs: + render: + name: Render packages + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Validate tag and find release + id: version + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::tag must look like vX.Y.Z, got '$TAG'" + exit 1 + fi + is_draft=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) + # The packages download from the public release URLs, which a draft doesn't have yet + if [ "$is_draft" = "true" ] && [ "$DRY_RUN" != "true" ]; then + echo "::error::release $TAG is still a draft; publish it first" + exit 1 + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + # Full history: git-cliff builds the release notes from it + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + # Configuring :composeApp runs cargo (Gobley) + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Download release assets + run: | + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir release-assets \ + --pattern 'spotube-linux-*.tar.xz' --pattern 'Spotube-windows-x86_64-setup.exe' + + - name: Render packages + env: + PKGREL: ${{ inputs.aur_pkgrel }} + run: | + chmod +x gradlew + ./gradlew :composeApp:renderPackagingFlatpak :composeApp:renderPackagingAur \ + :composeApp:renderPackagingWinget :composeApp:renderPackagingChocolatey \ + -Pnightly=false -Ppkgrel="$PKGREL" -PpackagingArtifactsDir=release-assets + for f in composeApp/build/packaging/{flatpak/*.yml,aur/PKGBUILD,winget/*.yaml,chocolatey/*.nuspec}; do + echo "::group::$f" + cat "$f" + echo "::endgroup::" + done + + - name: Upload rendered packages + uses: actions/upload-artifact@v7 + with: + name: packaging + path: | + composeApp/build/packaging/flatpak + composeApp/build/packaging/aur + composeApp/build/packaging/winget + composeApp/build/packaging/chocolatey + + # Chocolatey embeds the installer in the package + - name: Upload Windows installer + if: contains(inputs.jobs, 'chocolatey') + uses: actions/upload-artifact@v7 + with: + name: windows-installer + path: release-assets/Spotube-windows-x86_64-setup.exe + + flathub: + name: Flathub + needs: render + if: contains(inputs.jobs, 'flathub') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FLATHUB_REPO: team-spotube/org.spotube.spotube + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - uses: actions/checkout@v7 + with: + repository: ${{ env.FLATHUB_REPO }} + token: ${{ secrets.FLATHUB_TOKEN }} + path: flathub + + - name: Commit + working-directory: flathub + run: | + cp ../packaging/flatpak/org.spotube.spotube.yml . + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "update-$VERSION" + git add org.spotube.spotube.yml + git diff --cached + git commit -m "Update to $VERSION" + + - name: Push and open PR + if: ${{ !inputs.dry_run }} + working-directory: flathub + env: + GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }} + run: | + git push -u origin "update-$VERSION" + gh pr create --repo "$FLATHUB_REPO" --head "update-$VERSION" \ + --title "Update to $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" + + aur: + name: AUR + needs: render + if: contains(inputs.jobs, 'aur') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - name: Publish to AUR + if: ${{ !inputs.dry_run }} + uses: KSXGitHub/github-actions-deploy-aur@v4.2.0 + with: + pkgname: spotube-bin + pkgbuild: packaging/aur/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: Update to ${{ needs.render.outputs.version }}-${{ inputs.aur_pkgrel }} + + winget: + name: WinGet + needs: render + if: contains(inputs.jobs, 'winget') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FORK: team-spotube/winget-pkgs + MANIFEST_DIR: manifests/k/KRTirtho/Spotube + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - name: Sync fork with upstream + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: gh repo sync "$FORK" --source microsoft/winget-pkgs --branch master + + # winget-pkgs is huge: only fetch this package's directory + - uses: actions/checkout@v7 + if: ${{ !inputs.dry_run }} + with: + repository: ${{ env.FORK }} + ref: master + token: ${{ secrets.WINGET_TOKEN }} + path: winget-pkgs + sparse-checkout: ${{ env.MANIFEST_DIR }} + + - name: Push and open PR + if: ${{ !inputs.dry_run }} + working-directory: winget-pkgs + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + branch="KRTirtho.Spotube-$VERSION" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + mkdir -p "$MANIFEST_DIR/$VERSION" + cp ../packaging/winget/*.yaml "$MANIFEST_DIR/$VERSION/" + git add "$MANIFEST_DIR/$VERSION" + git commit -m "New version: KRTirtho.Spotube version $VERSION" + git push -u origin "$branch" + gh pr create --repo microsoft/winget-pkgs --base master --head "${FORK%%/*}:$branch" \ + --title "New version: KRTirtho.Spotube version $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" + + chocolatey: + name: Chocolatey + needs: render + if: contains(inputs.jobs, 'chocolatey') + runs-on: windows-latest + env: + VERSION: ${{ needs.render.outputs.version }} + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - uses: actions/download-artifact@v8 + with: + name: windows-installer + path: packaging/chocolatey/tools + + - name: Pack + run: choco pack packaging/chocolatey/spotube.nuspec --outputdirectory dist + + - name: Upload package + uses: actions/upload-artifact@v7 + with: + name: chocolatey-nupkg + path: dist/*.nupkg + + - name: Publish to Chocolatey + if: ${{ !inputs.dry_run }} + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + run: choco push "dist/spotube.$env:VERSION.nupkg" --source https://push.chocolatey.org/ --api-key "$env:CHOCO_API_KEY" diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index fdb64445..c3ddf0f8 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -155,7 +155,10 @@ jobs: timeout-minutes: 120 runs-on: ubuntu-latest steps: + # Full history: the metainfo's release notes come from git-cliff - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Download maven local uses: actions/download-artifact@v8 @@ -196,6 +199,30 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + # Portable tarball the AUR and Flathub packages install from. Like deb/rpm it uses the system's VLC, + # GTK and WebKitGTK, so it is packed from the distributable before the AppImage build replaces it + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Package Linux tarball + run: | + if [ "${{ inputs.release_type }}" = "nightly" ]; then + VERSION=nightly + else + VERSION=$(./gradlew -q :composeApp:printVersion) + fi + # Desktop entry + metainfo, with the release history from git-cliff + ./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" . + + - name: Upload tarball + uses: actions/upload-artifact@v7 + with: + name: linux-tarball + path: spotube-linux-*-x86_64.tar.xz + # Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages - name: Package Linux AppImage env: @@ -241,7 +268,10 @@ jobs: timeout-minutes: 120 runs-on: ubuntu-24.04-arm steps: + # Full history: the metainfo's release notes come from git-cliff - uses: actions/checkout@v7 + with: + fetch-depth: 0 # The arm64 runner image ships without an Android SDK/NDK, but :composeApp configuration needs both # (Gobley's cargo plugin resolves the NDK directory for the Android Rust targets) @@ -289,6 +319,30 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + # Portable tarball the AUR and Flathub packages install from. Like deb/rpm it uses the system's VLC, + # GTK and WebKitGTK, so it is packed from the distributable before the AppImage build replaces it + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Package Linux tarball + run: | + if [ "${{ inputs.release_type }}" = "nightly" ]; then + VERSION=nightly + else + VERSION=$(./gradlew -q :composeApp:printVersion) + fi + # Desktop entry + metainfo, with the release history from git-cliff + ./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" . + + - name: Upload tarball + uses: actions/upload-artifact@v7 + with: + name: linux-tarball-arm64 + path: spotube-linux-*-aarch64.tar.xz + # Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages - name: Package Linux AppImage env: @@ -507,7 +561,7 @@ jobs: run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md - for dir in android-apk linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 windows-exe macos-dmg; do + for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -540,8 +594,10 @@ jobs: linux-deb/*.deb linux-rpm/*.rpm linux-appimage/*.AppImage + linux-tarball/*.tar.xz linux-deb-arm64/*.deb linux-rpm-arm64/*.rpm linux-appimage-arm64/*.AppImage + linux-tarball-arm64/*.tar.xz windows-exe/*.exe macos-dmg/*.dmg diff --git a/AGENTS.md b/AGENTS.md index e61c7d52..117f7d23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,12 @@ - **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets. - `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. +## Package managers (`packaging/`, `.github/workflows/spotube-publish-binary.yml`) +- `packaging/` (`linux`, `aur`, `flatpak`, `winget`, `chocolatey`) holds JMustache templates (`*.mustache`) plus files copied as is. `:composeApp:renderPackaging` renders them into `composeApp/build/packaging/` (`RenderPackagingTask` in buildSrc documents the template data). Never hand-write versions, checksums or release notes into these files. +- Checksums come from release assets in `-PpackagingArtifactsDir=

teBTI@5-@zOML&EE?o+jfAS* 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≤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�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 yW?Nhc*dDsf3%p`o z)%~+Dm7I*mEkpA>%;w2N%CpnY8Lp@=nKq4755z5T@bD83nefR-P+;Xa^gLA6 z;ddOLDTtVul@HQwjZ?N62ZJ&vk%`C5uGlvsV4?~$ZoV4~iSLP7Hh3DpYfNyvjK+9M ze#>F!AO+rkTzPn?IjE!0)@x_g@a5%Ve;jwjVrP{E=Gmel3e9*7i56C#9=Z0hV%3t# z=_ELmRzd-@9(A26E+zi}aKyw;Ho4aUHt=>`z-uc;vX7>3x%P86TLD@t;s=j~{jrc) zyiY9)LaFI!&Rq|wsw@p$$DuLha?i$dT?6)`EpmM%3hMt#3B%N#g;@!Y4!TNmmaer4^-krFUA?nY{{Z&q zi%a{cj9>P2S>Z_L>6Un(=DcL({Tw!`pHzlMcq;ESA5d1fr};3tRz;Q9>(~|N5gnt? zd$22(hzCKaiy!$`Han`+r=tVK12e(Z+EcR;Aa6*7$?ch4x7c-SR%6TULIDflXC^^m z)XQ5rn2<`u-ctg)htGGY(tH&;DY%}U(0Y4DWkQ)$Mq3_WXDK*_JtB!BnBj%NPAE(E zQ4OwV5K|Z<9+;F&Mr-5kGQsjXb<1=Sg~I(lq1B|-7Ki#96B*&M>-xIbiTR9o;gpN2vga@JHxH<0 zV4$EU?h=HTT7bQ~YLMkig2KH(<~-}VKg4ZB#7PU9`_iOlwxTuZ>c`nO^UYxOnJit1B>1s(#w^-15;qA9zr(h6OtVs>4E3BDeSb5lNu;@`cb2FFv0>PzzLzO@P zB(aT?oR8ttTay0(#ybH9x&Bw)tzYwY^tt}S_Lg596hzGS3aulPaw zCa~>*JdnfyKqN;SGDkTcOKGZk53Ya48x51m`D%Vyo-F?Wl$?54-AAIGCZzT9M$Z%e zX!2kDLC4iqi2Lo)G=1R{F_rcW4g}csx~n8LJbzL~e*EsXQBBT@HRjc&CjsOoqu6dfTmm$^6s$~F4lvJViTVN0=; z%e;;JXFrDxpjLGUK3*cER%t8TaCEp_n!X%B@>IDwLwKDdK;?8<>JYoK@pMKG2evdsgm7t47N%7iY``1 zX_(IsGt=B6GSYsU`xA#8gyyXJszheSoV)w0nZ(a38Ol@5G*7m8nBsN_iuQ~m()~71 zu?~hurpgs!q?1UGuQ9qMIj=V1B* z-LR!!=@4_tO!-VsMgt}=W+(WR!c#?nhKtY)8$sxt&U&$s*w0on7&GlDff*vTxJsr$ zPubdkY41GFC34e&fT*$g5fSV*Tj?ruTUH)&#%>^b7n;jGQRtpsN!D~dI*o|mt<~qd zxiqL5g*vlr$&5XHVjd$|X}ZmfEWEe+jymrl`-^U<6{d|JQX#B}h>j!6oafAn00{DD z)O498aRD6kV2}M(K(=_BHH9G&G7%W1#EE49>$n4t`#}7M+yG}h1q#ehQ5&jPdqu5? zi0v8Tf6W;>x3pv9GDalk}4nV<| z7}O917HdzaC3CQe2Caip(PFBd8=3{Ms|yB;s~4u~RG+bMtShKcfKdMc+-Bme)7Y>w zqhS`(?A=>M%f7lL*Ey4V1_f42OKGm0g&Cv1TZQ)vi+~il|QJWqRfp!nCI0o{ReT zP&gJ~9L51e$MI=xdS^At=z5Cd$^t)RFg{71^Bz_#s<86g8_Dkod);KYVm0k-M9TGw zAJ1SSn!N!)dl`A({{TqJ0|8NSicxL0FrnJ(4_MJKrD`k(5@0L|0Y(#XnIYoI}p@7JS0l>_0k@G%4B!7redEje5 z`>K)`gB(W!YykNK>yBW|R0L;A{Mx!h9w+|*{CUav%9GVeUQrV>5eopRR!FvY+p%<< z)YKi?p1o%!i8%(-y81`dihd;nkqb#^JUZ0X!<1^8g*EPkmeJAuIk^Edh5rE5Yf|_& zMCUQyX3g%zwIZV|&oGM1uN=hWkv$P%a5BAI%>Mv* zfI(3J!{nxOTRnBevvXQqOjrTf;&4kSf5nLNeMMOAYolFp#&!|nEZ&)!Qp9F(qC@V7 zoLQqeq=HwkROi{^RDf7hhMe`NdYcfdt^kiA7?|f6F~m-45VuPj&9~iJb6qnQq9XXj zsI$XHMXQc*gh;p`82n-9=?^iq+}r_?TVC4**3gcr#Z^@N!d4#|N8D9DO=AQ&Qu&OIl`NY5h$<+c9+0Or5^XXmj008FJbGy^<5;!egocCaTw!c`7J z&`9O*f~d}XF=e50QxSv$V<1~pg-gu^6WZ9|{4H5+aXj;Fan|k}H&&47mVXxBXV!?# zLCNdYGGhia1amVO>HB9t-y;qpX9hlUk<85C40UsjsJUTI4HWfYR6s{)h>wpZ9E^5j zj%SHT!7?IqdS%w7W}X zSj?4#rf7Z^jhA&*ScafI`Sh8ZR$Jc< zH!;R!6q*u9o+XFhsr+G;?gx;b1h`*;l{C%I@F22*VS?RuFlVt7-lwy7n=67a)j+^p zolYY0NT$|h{{VACG}J?p6GJvI@#8)LQr0p&I1LvS=KYq05d=0REWB4ou10B|C`4p|zm28TJA|*h&tnGF6n>eMD1+UR}O3 zuav({^DtyP-2qddR7;X`c9$%~lPEh%i%w@wm@ItdBx-|v>?4fnb_5G$htfMh%vi&a>;R7Js!;^}rf0&_NG=R>X-qUlScKnG91kHAsPfXv zqBlh(7cdHRX^WUFtk`P52WFd$L3SUA{{W`EEF_!__n&^<3}B?2(_^uLDu0dQ6n+dj zgEFj}^9Z?`XtTt|!eq~3_V^i+Yy=i|_n!mr9cD!9~?J2vZw6Acvfdb<@$UGLnCNxBB}dIbk9EQ zq@w+~vCu=QxiDA`^)Xs4*KsHcRXKPEOyI^UY66YuPApZ1gi59QA1lo1rr(&kBIE?B z&xgu>DY~$@$4KE+fNYT$sA$l$6Od69t1!I1Nl6#&9H1jRM=qzBY@l9cNq+!JLt745 z`O3`vF2m4cq|o-{dmhC{OdTWYBRNsu)WSf7-vThuj_(kRO-0|4H&Oge3?*1-L^iBX zNyB`U!bv9$PN#tpSp95bAU;s_(#1jP$uRpt)x;*tubG|&);UUyMwwY-8ua*RRk^16 z#^^suIYhUf*#2R;z$TfqSeW6;8()#ypjKUfxKFeeaSx^f&9g#Tf3_)_0dw#(az7B3 zJe)H{Bp{ZujG+h=WYhYVgj0SeNMZElE_|9EhKBf?7yc*AMc}fiupjU|B3LSj#8Mw_ zsj1(AEVSv1o=v8jAmB@r5Yj;`U4>s0UbG)IKx(9n4oN}BMoV{hgSrtTB?YA$Mo8BH z=?0ZjKv71+7$qSwkVX(tLJ$OgZ|{BHzi{ul_nv#c^?FHa2~%K*5o+FTD4}L?kyw}# z`mVE{lbZfw8Q1R*9|5f zRP>grjH0vES=CLwi633tK|bcTekaMn?vVwFV879L ziyS;+!P##EDMPS^X@RXxJZgJ*tFrW&Z6YO4rkjB(WkD){80oQ6N$Kx2^#SeUg!clbM#(=%)8_2VEJ%VM zheUsH*7N!g&>&6UQ>Ph|N*lJB{fgSGN$`1*DJwJIX7i#3mx|#F#S(r>JC%hFXfl>B zGEoe^mkGQCUl`_O1Z-$ zL5qdYVM^#IBj)=E+TN61k?ESV#zSIdh{gBF2JG?^N++yF<6n0dN(EYs^c!XE?H=@va+?!Ky)qW=yM?$4!Pnk)I#VjKMSQq<( zwfeO*d?|86%yUNYphjxJ(Y`HToc_15WF^l227 zRlZyWqL zWZa1gk;mqf;20o>rbYbgec6eGOr7u3xyJRW9(Z#8g~pY$?fxd6Yh~1AbramIJx)*` zLN+0z2mb>=wgL)?$Un1f;qtkg(XZoXJtU;-Vz9xS4PYY;aDZ;RLa;%B>QBjqeqZ0; ztlkpRXu;i*$#?9QjsGZ?dL8`>XGw3VeP4?@^1prU)m5sqfs!XttazQD02sPA*d{kN za|^5ic{E?E`-g(AiYr+ZNSK8A77btbWGAA0a4$a#?Gr-~3(#dORSk}xSwcNHX>Aw1 z4$fZb>P)MsBeFHJO=bI|WP#4|p29El2$~Cj81A1X$~}Qo0L^L1si`YA4-@Ki&}AL3 zQ^CUf5+{p6tS_H_D7$sczfN-DQj_RF$IYE|zknyUvt+()DI}4iFLXBT6+l9tu{mL9 zX%iC{t1L^E^&*)XnOGORHpR#=bGTA~SA|O)Q@}WX`jvn|JY9(bg+4T|tYiqr2T{%y z&$osgnOtVSN&GVTyYP&)3ry8p(%L{K5Cn{CJ2{ zM8-Da#@Q*rmonLnQ%^cvi-D3TFY-w?dvhAoYtwBf5F>Gj{YPQcJBdJ1 ztT)9%pYmfU;`)U^$8*M0r6C4cK4<)aZ%va?41h?Uvg}YAdYCpN^?8{tqUNx5Sj?yi zPKsMhJ@y#xGN6xpX+#d-VFfrobcYoDjsfka8B+eZg@6y70)^O(1OFH0vy^kLo36QS!O*=|IM*d1v=57|{>- zY2+WM-&=M)cS@dqw!VieWj`O4@DoB>)9H|fN!_!kc}SprP|WuMCm3i$ZMYE8P#Rw| zMf{FMB)a-{aHt`$(;J!d6*&x(nE-gm?Fpsy!xXN~k>&usYZ|)1J&WxZf1+*K`L(=l+$Vi63gXk=CI-spdAvG`W0>6wzYl=%2qn62blu?*0>-D>01o7c zLb0byd7(PFNonH@Rp&2CCKXFepefkE9%6AvJSq1@G;S{;Rj_V=t2Z!Fj8bb!V3^+~ zBoQu*Z+fqJGEco}C;U&{skiVzXn{iK1Y{G&LZ-uWv+ZmqLmz^_KCG%pBDjUKM5yGB zFf``LPt^QDkvd#5l#>SB52O{ibMG=1a2KY1fgEzT2)qq#hgKmj6EC3u0kT=syHX9L zeM54%cs|>yKk^oQX7F6OKqg-@%_ErH%^&8+6sw3aJS{m5Xx|TdM455L7jGgazTALl zXMCa|@ag%J$<9J>N1yz-HUHufs%z-eK?QM#ry&c*kY&fyw3+KR8BYhA#UXXhm(EJwA{&Px z>VLvTAQbPZ1a3Udl6twV4?)U7HXxAiM?v?|hbe7#pJHi0}?d*jjJM&|VlHil}&Y1n&IHbsQyj554wul^v==G@Wm+ z9~bcJLI2nd)@+9_LyOv&97@{4H@dGwh$_cMLZSZ$N1~37xK8Zv=~6IdJ1$7 zwdd~f>)zuI{u=Q+Hgb%W*Eh&r(WB;j!W_$Ycm?=E` zA&460Bn$;!qIFW|1f*;;N~U5*AcE#n5jU_!Q^hAI9V%-?WLg~WF=-#Hs3V5_d?wiV zk{VLWd%7u$>Yk1sEw@myMw)gmapkg!2^QY!=8TpKiy==nZbqXRZu)+r(o8=v6p$Nr zpbLd}S6PG!A{eS^kX zrmYR-r&Qh8Vmuq{rNx*g8HAv+PJ%*SXm&CiO^TysTfz>)+QRl#7u@#3Nf@6DoRg!J4CG; z5_#XdCANGj)q(9*HkY7tLHl0a?UX^?ugC$V&K5#@Blg~&=UKMu*MC)Jab8mkzeAO5 zC=um0-^>%+Te&OFf#&~GhvX{i{e?aml8u;>aJ?Sk3!CWl7YS-{xVSAC;HI2t8MZB&f+f1%hKOIJ z-jJy1wu8vs#234iC)f>1-SSWAqx*x;ix3l}Pie~9Ih_ZoIMzciRUHNYjfuq43?|U{ z2phblE9jM<`|!DPyY-dO$4aXW+tMKZ_NCc>;#l(%fo8XPUx1IPlokK+vF2asK@o@d zS%;cy=h%i8OlGx~KK)+tXcBRfzHGoVf`&A0K*}2V#qqf%cLGD%n=Nu()M5a;k_54w ziZa9NPH#jqrxEUffT{LBikw#;5CyPj+I%+BXC)7f_lW|FvlI5&+$w9l=Zku*4??Fh zjFXl3Bi&dSrR8~I&)mI%x3zRXXSs!=%x-WIHT4RTCX=svT_7YWVuHc|^! z;Zk+|uskQ!LFvH!I3QiIxK%n)W^3bK-g{w9_7~^^KDi^;1GLoe_D)RIc#DN>eAL%F*JY*dd1hpQDP!RV^_diYrNBK?}EJ^g6 zy3y%7dE)032xJ<)D-qpq+BA#sGcbt03(7r)?~-nwp+4O%yX-t+Nb<-fNf(yzgZ?PT5^bb_kVx^bT`W#F+ggh<%x~1 zqqpubZ==M5X0t)cklg+_S|Z{mioR1KzOyjZpC6)ep8RHix``5p9jfJoEzUK=-zUmL zN^m4mNQk_DQO{C-q2?8&*iiWVI}~hFpc@r^Cq}9} zqG5cEiq=b4sg{IV)`Lf2nz*L~({~3#%Kb1+jqV+4MUN)BcX)|-c)}Sn7aw=Vm`Tuq zIi2TZ2+IYC1$Q{DDCK=MsbkM+KMz^It)`0HF>f&UE+fiGrmJs{71&`;ZOM}bC7fbH zF`;ibz=0sH3_oHs=9G&lP1SBscmD@og2zqsM9 z&yq)q+hu+czu~oa>xr%T)H+PvC=ZSqTZE5^3@#OMLO6<$1I)4RO(F~NV6-& zxT@70Sd`jbzQ4BFO6XmRsEiqQ1S=WDV8w8x*{YV0Wa70kVniL$+Dp@2-bRNvG&1OB ztvK?sPc8R&C6Y^GhV(Lw`)2hAvOq+$$19rY-=aC-`wsQdRM8dCq5c9fN&kRtooUil zS5w18sgK_5-ITPFU+bb)j0jSmh(b(?Lz@IkPc!^%KNQCOQZKrTJ>4|H=gQD$VEibg zE01`2oXmWN2LIPQfX*uX>Vvta`eG+JT|2|{8!mRVuPoz3@eMLY*zdvhu65UsJjggP zo?;eiYPq5JqkIZZzrm(jJI*rxl^k=UB3q8!Qwm`{phc}3;`&eC(*c*NM@w!r>vsg? zbB)h=`Aj6g{Ts1;OrE8u=o}FFFJ~?sNLx9ZWQlM(M-B1NzY%Q@pzJm5f_+!e((|PB zelpK!Hsm91CF_ms^B$%l0&Sf|k&N!uPDm}dUxL|j;O(VdbW`h7xFY)u_l`fl#PEt?MNye8oD>ca%ZXDjriTY zXm)&6PY_Hk0xRh8=$z47u_W95gE*9<-(~0FDl!5xg4e~JTFme=S{(hCC7HQxP3-A6k)glfSIW2&_Z41sK-{5;!me4-|Qr!?i zf<&u=NRct^Qt;JA8N6asah_7|x zeLHZb{32q+y(0s&q0nDLeE|IAM`;62@(+7o~;S>;dOKm}Qxf#3M1qRJ>u)A5s4VG8*L zmUqnzKD!b)InP>So<;TKa0+hFEO{9$<^N+1*ZvFpp?Gz`)4zf!&$;TX;}86*#F%!N zfR(V1rQ9Rumy#bN%M+w%HUwZPi)&rBGK9JlgU|WcXA&J)ZkzwGsznna*;ogS8Fvto zypkrQ%5#kwXR9v*oyc+kK(+^J$Y1hrX$z?g?c9o1vb#5gEeUF*HL-Q;4IUK^Z5MmJ1dJ`GIwU1RZ+ za->$h$~kkRG7}u2$V(P`!BDHNjr@mknL{Rrq?5VSCzv7 z8M6lx-`@H{#<+>nFd^&p5Gsni`k3#N2;#Z=9VjcnqPHBUAHV2Jl+MV*v#&68uYh#c z06nb7LHR>pI94>zvhRB)#eIt>#O7|S1+RHstTmsh6+K}upiG(SE>Je@K8q4wb6id7 zimULdKx&Tx0A7L(2FuL*TFJ31I(=t*U`5NMd~VxAzHh0kS%if2K>hA=FOeY z-lf3_aFvnbmX6g>E$6d0%TV2t8{=Br1Ea=2&zJZqBbnEZ*=Y|`zrVbLlDA&pU7%#g zsHMJ0$vdR1``*sNO|k>BiIp8pv>NLk)sEL7Gy7%{6_HH!&?qY4uDp6Zd2xeCYf6E# zeG$lr8myFemvJ%cIIr+`%z`Ylb)WG#w5e>d>qO!Xbi3f7#hVu^`$N6(7mIBe&hc1< zGd0)a#|ui%2JR}V0ntc{eevc})mtQigXVX|67bt5kV5Ru;CA%jM;kR^=(@t@dgX1Sdmq4z+7U`ZdbLp z&_|MS`#(V4Hkcp(%0q#Zi6yLxm8gc)JNGlrvTj6P-_K3IYs8T0g*Q+4w{;(y=+cOB z-eD(6#P74nkdSpJlhnx>n$bcaeYDi@p&QJfJ{(9|7dCLNp%moynk?y_YrV_?si_)W zHhnu^_DnN*<^cS?KgIA4ntmP8ZZVZ}<(a)0IMnIJh9i~|iO12?MJNo1%d&n0S}&t*PgBU~$- zgid*F@?DG1=!Rw8_(;2=#j=#!Hnpj47Z>ZEB^^*g9rW>}8KlKFgY_2T^My>MsOdy; zo4B?moRwgE4aD3JgUbpvKm2%I`A5{7^g`6@4UrUisz1-CX!jznn=@#ct)-3);Y`&A z+uqK2s?jmzf?xf0@~O^7h7BIXcHlL7h`$87BRg9o*ToyY7)l@?xbk zF3T_AEA*}$r2{Bt^}z1U-yFRROakv>!p;yfrFym)GfRLQcZqZGyyt9oA2#>V5b5rbpU?xTvjsefeA*?*m+XdNrwZxQlqRW)z0A$2eO(dwVcU0> zb-sbTJFCb!|wMbW$BRJNY)8{IUrA2hs9( zt)4t}?sd{Q4%Qh+hhApz|pyH68aqf-c`~1-W z`nM{$joxqZ*T&+@Jt&7dn5b*-8J}Sw7(7f=I}g z#$BsIuV8M#xC&A8sKY{Z3)+G}Lg2mhqLiF4YI$3GLI-cVek~f?+O>1?h-QO$~Sa)7Sbv(C-?zWUH>i-8Q$hG*A+UUjFNGlK^Y7z7- z2knvj!cj?x|1aY<9O?0cZp$#~14m?6+l24J_-vScL&Of$?1@cu@X_0&KnWXKkMB1_ zzypf*b%WLXe~d|;%DqL(PRvRgvE=QtCp03@BlFCch_3*ymb$9sbwGe3sdDyxp~bjQ zSXCE&EvE~`uKR|n09+@-jeJWCwgMC69;U#Pg2+ytt}#N3ctIP=av;p2FaC1Na%cF~ zH5pYrE0o3w;eA8XKYgM+XWdC0XkM`)S%^*;_IqfRFUoTluaz9`lyD|NwyoTWl~oxI zXVQ{#_p%Z(r#W_fD+z70*aHLat?Tq?z5`Vz?YkzIai_=D;hdTTPwlD6{+%m+;u^QW z8x9A4oN#^}NAwiAu=ZIGE@0y$a^Jz5w4AVDQ915@YxGtRToLW#IV;_pc?5a;$dgd< zm=v1zx|A(F0+Zs6O-hl5%Olnt`|)J7tyo}9=fbDb;4i|Xy)?0lbWz6uek6S?{{t;A za~YeC$- zYrQXocttDpnobwzdPYb@`2GVV_k{ugdavs6A?Oy>IZrF$vjXnz=&? zmyzUu0F$>hYZRrm=3c!(VHap??s{Hm8m&!=)nkQCdP{PB8r;*bPjn~bg5R0O|Fd2qH>+tmp_7xv>TJ08;YBm>qj3`HDmz+O$VKS)f zm{;*bwbd#QA6w2jLj1=wwjo%A0RXP4=j5bZX@9KTsGp&I460A1b6QYLp@gfDh*Td< z*%1ByAnWG3;r2*c@zZ!SWR0|4mfNrWXfgSgmi#Iy^OV&iRfwO?9H_Ejfd3hFL0ysb z80r>&2nt4z?wtzR02&(~jB}rNqkc+sLqq`$PB)*H2H>zotSuQpa$9*Vby846w zv%vlp8@METaJd%!!mt3*pn#n)s9XKDmZW_)ax628xaYT6*X3aR$H9Kem?9O(cH}#j z8Ow|)r;o9|Nq6hS8~?DlsR*p)iDekFl+@q(=*)jt#ot>CIG~r%aBPw-Y^GPcZ|hJl zFMg7<^6hm6JqO&MaoB`zxSwsy7tre@`InTkgmf6938G+mKGgg6lf-hz3n8E=W=mrf z(?;1@7)xt)%X>i-?3A z^m=-%32w;|y%Q>gRT>!9$bH?NH=oSRNVCCT5^Y#6*8!&9R zqpD&1uEQFOYQ`4!)pRRyh{ha>SeVu#@Wa;7`}cfQbu~UFGS-uRY+U&hOEGvf8$o`PHpUkr!&k7TmibN11oW4%B?> zh$lqATB)97E1e8Z=^y|m zmE}PTMW0|u(}u6hKN+A78te)#X|pobN8VR_4HyAqx5El8tZ1rhqXXQa+}y!+2Mf6M z55V7WI9f}hnYAOUW!MFlozQk9$0+4Bu+XQY zx|+wVg5W(>YUF%YTq(gCFgkP@r@B;JJWi%A|NTK_w3K{*$=Ax{5F#mwDCiCB$QQJ_ zVBw7Dj2Zq(A1gAIYQMq-!>KC{>`*mdrp~?ENHC-6jY{OY@6ZGeQW!6o>_PVn2R5-*wI{50ny4e?i6XTw}pJ$!6-#8D@N{**IOEX z@ULYMx{{U|XZZ_7#eA2!ox-=K=0 zjLW}MygFY2R&N;-O;f@(6% z()G00c}+PuTJqA%6<4uHVi zz@B^Ak{AcEJ<|cBv{p^kUEoJ9{QgIlC%pm9F$Xg%?w5TnDuyE{)vI|+vi&1z;_ynu z7d1bccc0hygR|C)n!u@drAHq@wQn8M%Ks5GZIaY+Dx6MH-}RkCl~yi`VKCuuG2F4` z2Rs(0myCP-j8K?B+$WB>*`3f=Q6>9^5Vb@0KkTo{0JO?$%@6#24&$;DhBl8yU>$Ms zAX0l!{~bL86JDx)*Y{>;w0w%Xs&|iE{K<(ekbj(H!r1mj0X<1)TsL(czkRnRjM&SG zy^O?|!qSk2Z4}t)m>V42U}3$p`1!d-=V>nQz4QGJbEzsF<(FO>N(SBF+(7Pv)cZWV zl>Y(zJ{Iizj`tvzLW-hUr>mRseW+l^2aYanV9QiaeG!=j_1WcnmL?K49O?9e!g`hN zjnrinybJ7?C<+(YfYbqSfcdngaPvbfh>qB0z=3RC@~eoKj7$*`%o`!SX~_4lQh)@w zCm1?0#r@U1w3r286GYXdeA;HE_V#16Ep6-1Q#}=nZ5JSu?6h1=vlvv zE5s)W+!7VK=$xJM$o#6D`e&&*_d1+uZ`jodK+LaPCTQx{E9&w}h=*I)n2`$3Q2_A096gp*xgi@M5zo-D7PAg(i5YP}4AarXT!j_cV&iIEadQ;<#u zUmi?Rh_$T-HyTmcjuxu^2bhY%K5B(4&X6S+K@$06N%r}cAS?`<6VA)hY^g&e3Uurr5+}9O%nVXWcLA+){1xaxhY)C)a4g$`=uLY zdT$qYi8%iQILx({+LWpmm;9={0S|11YtxZ9BWoqSsr7>oLXG##m9s$!GxdeuwDM3x zi{<9WsY(~PX?^`l&9=HOj?^%HI2>DJOmP!pJL5E9_DV_+xm^TPMf~I_ zHmrY3;0r2DAcYaTEE7Zm(h{GqA}Dm>ORNJ8b$pgTEF1w*0)_={3tnTfjH>0-6#HsI zF`YLuTotamZB~DaekwS-E{CS^MboN0anBvuaBfJL+AcPGX$lKE=8jUXtJuXElONM| zCHZ33{kECdQ;Nokn!6Rao3Ka*KoAz=P?7D2?7HWESscvce*XFId%eGH@U*OnCvk_WT)QclUGy(PHm z`QnD7z0#Z&0xSD!VCDa+Oy~crY*;cm*BVDcW1(ayO0GRq%#{Cm+9(^=Iwa=?RPz;@ z^_|mY)V}znppna{S|Tk5g&ArAM=mAD?VLf2-Sf_v(6=p-0|$NVkXc9h5(W?ID_s_@ zX7T(7jgg?dfO=U;_YEnE-v0o4@d_!aZ6$bIzvGV3o)z%YnYya90UAn?95bvcSHvDe z04%E8eo?pxv(j%Nzm+;EUMWZi2W#;@8Q*zBoZPS0CLhUv`C?m#{n5}*E4E_sS){ja z2S$#LiRFdd73CTF%}8oRBi%p)Ken|lj_doiS0vE&thZp^T}Ia1_Y?0N;$!>5l3N77 z>dUM?Nl1ZD)Icxv4y=S3w^`&xdha0#5zAPs<{e;EBnB>E)FY5X2=&gGTT+a|4>qq9 zdWaN7@NP{Wm0rENE?KdSO3A&)jkX*WTzkqZ3Y!kPqq=mm5>o#Ne8@koniFY$U46&> zhd>}bS5;NL;nGle~R1m-h!5frbqPwD`%>By_DVaKy zExrZB-sZ|WpdH?2Ygtiyi%YdnRv$$YTR&g7*k*03Q?51l8-W?J`tyEl%~ED0%+Y+Q zD8%rrgP%(pm^nn`4bw>JTcBn9s6ku9UJ#M3R^&Mk@3)Ap2XTjY%kT0^)9b2e?k4ug z$QxuDE{p?+`BD>*HVnE#0C?&L=+H?DK!vBNPCHxA?U+*;DC-85cv~w46C{y3W~WCf+V9|jqVMxiWIgK7B9&xzkJP~6_EHVmQ1w~{i6O) z#}Kr`b{&?(sY3PX_U)Fg9nOE%5qpb_1lG-iCGb!+U6!VK|> z*6|E5TO)0(3hx8==2E-XtpgO(kAB@T{|^9iL3%}W5m1c!_7&~lLW;{X{FjVKKChXu zK*0s)SfOXjG$6)H#+?KV6(L94S(q5iefD(0W) z39@R_^Of#Q^J9N*R7hXS*)RGAU#%I)s%XJZhvNO@_sElG#{U4rv`HG%J|-HR&+nJ> zEx1A->BraCo})jZJ7a$d7rq&R#_ql~t29^E!@dnC9K<@SEfDprzDR8)_XdgH$MBIT z`BJL4H)Y@q#y-i|dJcxwwyPW|;Xp>g6=U~iwgO7ANaFAbu}5-+RU7i*B$M=-zKYTUmf4`pPFVO6`wF!KV%Mb%=g ztC*mFA23o^=UtIrxznfcI5ih~ktT;3xh5cppM%0uN9kni2pIrt&bM6Ti#ILf21W7z zDPb|ckq@OlLdLQ8M!t+DYqS{ePGpRgx<{mBVw+x!-`<1$0INHW&fA6G?RQ1|oBZE> z3S5fyXj{q*LpQltHvJ=$NgSbxI$9i9?3rDgta@=3u@i1xMBs*Xbq;HS{sWl3J5HZY z=!m+_nUW>{daA|f)jS6+WS}^3u`(gf3XHa64qCM0Ac%tkVMoC*)db8qfhUw$@pco5 zGEEpTNM{WPtY^!l+-TnD9ky3a$=Q(U71>GfYQ~MI26UC2o&r6ae9S@3kGD;po!i8e zbGVD^x*#PA8Kc_lYsJ$7mZHyJ&2l+Lo6S2bk3`LtxAjN#=ba2yGJeAzKl~4HypWZP zAf@-!f}Y)lZc*3;LE*m8}W}1TI z{lL3(D#oT0T~uXDAJ*$@;(BG2joo+-A#Lh67{EJ5dhIIG%#oo2x_$q&E}gC)&ilEH z($fapG&2$xC1sf9F(x)gw+`RaU!M1xQSp&Na!(NdSQ@h8d7ePij=N((x{xVd7m5DF zczljf%J5r{O#78ny5oke@orctmFS-KDhqL{j8S%P*d?1QvqJ^t==?)hL@6_B_4h3r zS_>WqU6%|Bq+S#+9nzW$6irsGchQFo(i?bjEZ(eZ`u zXCFBcu}Y5X;`|gdt!gf@2dgGQhj-3AUVY7Uf~vavNu zi{gGFP|&lvmQ-=69W$v?m(Ov)C?O1;Nsf6H7RPW+B08spQrH!j-+H1@{p9e;%mAPRmmWQqI$68Wx_@*_U3SEQ8S z7d4A;>VaK+UYnfs{@eiowCx}UZ-{{<^vCz7!qk2WOEEmbumIN#aSQg;(*)=8!q?P~ zUnIB%;fL(>FC|oktYqgVqbPG4B+p-5quw*fx;LbLM&Ndg`$zo95;2}7ECSu5X~xKn z8I9$F>W0jtGBAZcj6I9cZP;dzOT+AKK^_Jy58`#!&o2ZC zZ24HQox25z(sNiu^)CQXBS%!aLC5@9Bqk@eXiK|o`2wIuCz)!-C-ILpNjEqs_v$*= zwXOUVP7M%d0T`y9D7WKZ_&7;vgBBRHk^9!C!F5gx;JWM!o&6uvS7E5MtcGowA`V3> zH-GQ#Pps;e{7~ssW|b~MUP-IH5Wngf@>aq0k-PWaYe0Pvozh!`1@0&IY zJ{|&6rD06aW<=fRjD1SA^&G@(x$sMj7w7?Z7BwvV1}cnCLk3U)OI2j4V ztEiZe$wJhvU_+?1{rbnuM3N45X~hz9`e;Vzgdm2pA90d-ohpl2%!m=(!uxtbuF}Zm zejqEPlF%haf>=_qoMCclw82^ejGtzJ>o;8ApA>&i;U^8440}xqn7w^80t#f^!P61~ zX$(o3_CqFJD*rM>#V6-|@r^evVl_;d&um%$Oic9YU z3FIibn|~e25B9<7=(O2s=sgbHdEpmCK_*#4Ey~!XVmPZs#Ow^9_t`bRWFp)q$zq1% zp8wGsM}x(hv=2oRN7Hc|grnGysq~!uRJwl~cCU{7nYH?s3t%|6IY%@?qijER#}RTS z`%E&hu>Ros7$E*BfK@aaoo1@gQJ7T7X~kaOzs-nlmFQLV$)uf0_dTMP_WO&FVe@^N z>k$~pU1YF%*OMqx@Hj1ocYmqaFMp&hX7fYjC3(hMpLvA&u6Hm#Sb}wOeaNa`57dA-yaXlGd zF5FAo|GlX4Csh{{0DV&<8uCX#^J9S+W-W4vE-=1m*73{H;RBz>05+_YMB*Ot!_hb@ zAEE%3>t**f;#~r7fErimMZPw8TQfQ!&5q7kP&2tspv_w`dIhSrPX9&+m>mc7Yu$B= zRzW{vpeu1Af%CRT!UQmk?oD#X(3k~D7M8K~XC~;Zt{(5SkOcXrnGOQ>M->gA(T#7o zZ1OHFx{o+%?hkQZZwm@gs!SUIcQQU6kamIg4ksbEx_%)jWREZrzvC?!G7(}`*KfLK zoQUoy7y?y-Ktf*-&1+$gt=s})S4SH3E#9>rbG%nk?LxoNU=$iR;f(*2F!CzWjCxLj zd*1Ia{Y~!%e*jr_ki-~u@8^zUd_z+^C!--exlEF`Lwzx zcu!iUR0?)YFFp3@tGg+|SCVmic%TcM>rDC|pqLcor8>YOgiyS~dvz8s;f}?K6uCSV zY1v}^Bm=L(4TCPV9T%!_vZz$R6wShScUGiDJJ`p8O zU!85@`<337lc5b$?`m8WvOl`*a~l%W&x&Us0zfVRvGKu;WbItrPj}ZOu1g4)FRq*> zQA{~NuJcTBDb`QPS~TKRB>;_gNt$4n-iI^o-#D7vjbRBdhLP>C4v4%-%ZK8Ql%R~+ zffJbpdd6WPlK3TxMRNmmhIase0Gv>(*!o6%ELC&8jLLwri}ta7PeLLvSQZ2(j)=tr zXE4{=Z{jFp;r0OD%I{w^Br20!{#mjX;hVTDa~b*OE@;h@Y8IR)$XhVda2! zlMW8rer7pVMz!Ya>~=m@GG)5EiJ;f(FWHAvSy8z<@|N;NlZ~FDSTe4QZ77s>*s}@r zlBr1>cggcWr3`uQQ`nN^EPY+96mMCV9OZ{df7gUEk|V(nr3P~bqN;Ot;*tuuBl*O- zA_=RTf${p1^5dzNlVsJv7NFOVM;te9I_HHOCaJECuBBgEeJYfOKw6UUO&TrP7SD*h z_Y-iZf+JrhVa!7Bu@_Kl$Cgmm7x}hF?~dTjaR4caw?+o@i4KQhYaMq4iz$4zcs>xg zNnW43vSH6A*k~RBpg+F1KPkr+M2s^vdFB;TLn}U(!1pvqh-m5`d{IZ0iQl_Nr{j%_ zBxXl_LCCa|a{1xI9;h{^mOnPWt7Ii}IhBsC2S3S=y=AO&7KoaG z57plNlH~Is>;|9)pgIdSDO(BgA&W@?NI2%wu00{Kpg#II7d8XVLvZ#BLSelW-;)_rNMF{{i?J zVXaBJiABT$Pn#J^cL0D*wl|RytgLZYdd7bx>~G1-^Et&-mMr})BtCACI+dlRSqlPU z>8xI;4%8o+@xHA!S8yh|0PcjA-^_Q9f4iUuhig%pekPL8%P`(>ZVH*b`2`ao?r9=r zmj30W`tpA9d1a(zjzjdR@IA!AAL9zvF+;eNoq!})l(rS%z(>++;A>g?9L3SXs!f7E z_rN7Zlu z{6R%k!dB#Y_iiHK_?zb`Mu9!Tto)8c8qSFB`;>K-4Nq@(!DG+dV(b1C`UM~Y{S zvTMAP0b3{ddUqQC)?^l3>}Jvu&mi8d!!mMym)15qnNY&P50Pr*nD<$Y!AEUd?uprP6Y zJ&nsO{(5c$8Ut)HCQTa-G&dfo>VflRQc;>k0z+cvCo2!ISTJ-;qc0*9Dy0 zq<`Q%Rjkqoz!A6?g$80h(TsySYt_c3y^e{wudb3`T*f)}z87~XSz=BafzpF!Z@1yf zPAoVm-#jFzMhfPrn#d3I%uaJL`(m#Lf3+gu;Ml1KFFQiS?g*60R9+%cH zNAI=mg>%ei{p2f|%tVtw&7|@36k44~m2+1yVL-3;7n#f-TNOQHDv?|Y?ll)5LefC) z@EVpN7%%y%{1QjHhZS{&=^gc1N+;bY>H%ybkB~TVHQ*oWcesD;6gBEGsXd3R1C9U{N5Zz zKY{;PFc^RxQb5E=e440C;DGmrubE0x2&1j9n`nmeo&)G3;8;G zKqp~%QWLOeLXlU8RTxmIkO=B@%+#x2I6%TtbU$W=4*co^uk3XqRjpl!Qp?aLo-+M; zG9O2q+fH6R*|X&$6tKmAo3Z(_x@g}k?0*1iL6yEWznPh)ddH*|M$LOonMfe;L;{oZ zwD!7ImaO}64cU#)ZlY+)CdTthf^pN|r~nuXF*gcy7&ukqa$x-`U%8hzF!{3>2~+uF zJ^K9YzY99l{{V4@h8*@2Z@`(8sqFc)oJBrnW_LInDFhaR#2XprQxj|N7#kZ!`@?*5 zIA&n$>HwAAEuBta%0WjdDyz;6(2@^k&osZg1>IKS4Ra^B!`t#1p>CjfFkRHz0-FZf z8?2-+vkhgdQu5S%H!9qTdg=Sev;7UMKx39&f!b~|fQDYm%%hE>norKqg1Z?_U0~Oq z(UBj#(BN$h_fvK;bKSYX*+!-Uqv|n>G&UWk*c*&VV{!_mtHaf!pI;)_7_TgFJM9j`Dly7nZm;S*1lEv^lE@sX*hkOBh3N zf+$cl0uZV-Fc)vE%8c#j24+~ju1^yEB-np+P}TnckBB|f<5`{b@`u%o9lI!+9bf=W z0GCw5?=@32IBFLM3s-CJ0a=_j!p1J)kJy7WQDOd~2MuC9OsSC1rKLtjq{Y!vT(rwapfQA(m5N1`WPjaBhlcAMJ25dn1xFFczj&!6<$fhn%k;I3LrwnxX_z~~XQyM_?1m}htweC@c!KjW$7Z0zl({D$B7)kg z@sGaT?Hsdw0o8vB%1T7d4r_}ac<2ByAG;AMsFh51G1^r!s+hO`02VRXe3%G*-D#2>)umSa#x~`Ep3o1HsKwO>oDWp`WdKUM zFC(~FDvqg})AwFvjhgoOkKld;Gcrwt@ey`u;VsfU$u@u* zqz<q_b8Plq{`E?8_D?(SuJ4^nS z*Z2psP;6uL^#O6%*>GbmI-C=M{W_;@jHb!KAlUpy>rbVn5lvrHa()F;h83jywem65 zmtv;kWO;()oCVlxf$OQg{#+O;&vKXZ@Ohf3vBOXfI~&817asvZ`v^Zxxf0H!afR88 z@t8uW7bKj3nNdUT`Qe|W_G+JXFsMcutO10izk!3QdYeytz!XL$_$MP2jzlCKh>Ldv zCmo{YA3sYNZB58lg6~9z^WM)MdtqVmSjezSA5V{QhR;wwcNCe?rQlK^f zlG39*_H;kwpvpAY(L@+LrPRlhE4Zk`FodWC$7d=?9Y*+@0TUs-9Yp9rmNgD+-GqiY zu?7Iv_gedjwuXDmg2wsXOz`Txy7 z?I@z#aH9BZHkA4Y)Y7ZJAhE%0!d?+X($fjfB(?L{PQ2`Aj!TnF3~2S6F{ars_ng&`%GPZlE($$i&2A z2Bz2>F05rcGT4SX{1v-ufW~D6Sd}&~*8Iw~zam$u`+wB-=co9$@fjVtQ2=E+f-VeS z(5dC&{6rud^tQ+1eCz@85kjJiC>sxkfSz=Rt$&G;=eI>*Pv#Io7Z?$1cbi4OnVA(c zQZx89?xc*4vY}Sku10%tZ}L!o)b_;x0N^LKAFu*@RYq?oF~7)iCyWc8o#x}Rnvbx- zI{fTobgvLt38v;Phhws+IfdRUx2sADa8fALKZ6K0Q}+=_rBdh6_Vokhc}}yBHkDN& zt5o^5r?S!(+>4oz^1$8zBc8$U07{+=$gI?0Ngu$9I#>6FzfF8*AVg7M2f=GG7ahyw z;D0g8A@CejGLd2o{{X`Jc^)CXI6)o1Nt!wwDfCqO(vaB zPPXMe*lSW=3z%7tEN=y+dW!pt*nYxMr&G(F3&!bjq!v8Pk7kqK zrNl?S8AH;8MeWX53`E%F@ONdz*bPj&uI)28U!t#q^Ewp`)3=C!`N#L8hj+E-6SyhBs`r?b;vL5Z2cXvF*h5t$u@fpynjR)~Xn+{gkn>ZWP5-m3oqGglx*xj8HsGR%1REChdddrEHq0Ez50!TPts zVFFym7zk}D5$H_zhnk$s(Y@R>sG+;FV=_b+CKLKy+6d0*i6FTFx++!5beCG$Sy*g3))m?K7ElCXjQo$HB9W;}|lOYwaxwf!0MwI0^sv`j^AHa^# z!Y~m>!PgU*Ac>o1t?DMJk@TAH+APj8@)DDk$g=?f7#mez3FKq%zY9H-eb~xj3K(nz z@G1Ppv4AVZ45V0tE_XDbAdNxq(x~j&U?PC>@fZLDATav~LQ`J~m^vvB3XExrhxm)O zPz0zxUPcR^@Db(Pkc21`^D-QnndUB}M&43lqvir5x$+Si81p<-&AtiurNVtp^B*Xd z_IF|lY=O!;22pzgGr)#kkPW=0a4FO`GPrlq)YuungpA6bAn2$$?Xg~<8K$2B)JpWn zt50dBN#TSyl#7_C8rpSAgf8R_AuY)#{s_CKzG7rVxu%Dy``9RyfkI;ftUE`&^ZE!& zQ`qqh)rG-~6$WLAuoBJ9Nacy_QherS47U-jN%C*?6>1|w&2N$^Aajusft!m>RFOW2 z0R@b32hz|64av~P>SNeQkBEW+{{Ra*m6V^jnu0<5LIPF(MA3z&L=ryzm;E%&ra}QJ zq5Dc7h9Xq0NtaB)hUPd&Ix!aldtoP%liL3PDJu1ru{!~2l5@;nmIlgkA(sU->TK!% z08`(kv-fzAh96@M)zmQi;^hFz%v&Oh0f4(51a7L0qRS`(!A5{#Yo22g&Aqn&08=YM zOe7JZiWq%kBN#IBB4@~%r#R+inW4&EjLK&T9!nIWZp{gEnIyyu915$n@Ua2A*eZy< z2)c4+bm>@?=2<SOoqepMa zgn%Q0(TImQNLZZ$F#iCB;Bs={%Q7}hrW>OSjx`krnF4!NGSfaF6)F?9(al?U)W9B- z9Mz)-M$nGcB}u!d5|Y&e!F{DnxKUx)C%k(+CL1Dv5$Cig+<_P0+3A=~N?%pKm;$&U ziLp{9sJX>a{l+mp!2QIqje>}<#)N;sV5LvEOt!1p6PrBOe09}~ zBlJhQ$IsxOSVTT&1L+)e%At9o0|Eb3au70PiqV{{Z$F^#|O_t^o4tkJa|H zM-Pj!uP1=Qq37hTt|SYQb)9H`teyiT^=j&kw- z+rSWVMa;~dNy9cmRO0GB^01D_(G2%!gOcEKssJ*<>VSUIqFdHhB9j{{tgP(dTq|z{ zh&T(=r*FB64nP4t{{Uuv#$_d{(`oPfIW|)io1BCI)jlSbRVprOcZWRHPa}v6`z1q~ z?veVG&vvlP>zk;6)Bf4;&J z>~vlPQnAZ|KwHgqCz5a;Y_mEIsEip{WN~n+C%H%WJ%axL0A?cI(z6dEoJ{HtkW^&T zIy@7YfD+4W;}&K&C$tj{qQV3uzyuE8;}EJJF*8te6Q~9k@flWBQ!Ib?h`;{;VG=1( z0b{@uULu)s7{l42#h8K42Gr{Q#uW)i1510~tQbKD766?D_ni1p zZ#6xdkK4V7J*oe?xDPuDrU<9RpTZqJ+$&~@YNS`#S7JEy6 zOsZS*Vv{l*p5Dk)BAMhrGuoZib2|S3+`0;Rpxvjl{=2951vA*%lAB({92hBMaM0w| zvEYM0{E>P?RnPwCw6RUsI|$^j0^m<&9cNc06Dk^OJOUH3ogtdnrtR!8)9eN%?1z9r%a`FZ!21ABM}c&rbK-EzFdwjVqOA~ zm-Pql305DGJ=*K%;-6aI((=HNKrn-m0yUPMnD%Cy)8Yd3PS^0mbzlheGy1hYg)p0J zx5-Cg=@5Jjn11jRZC98A-*J?z9g%?+1Xzhtx_*#I9iuK|bxSCKv|CC}^14#;wdLzH zq>2720hxyu&to>R2ascVr)yXN19aZOd6sI;spHBKMz0`c5W4KwiElFvyGV`(0s?-< zP;tx0%1nxLBM)e*qkz(@ zxc=iZQM^z9$1p$C)2dltpcQz9shIj_(!c5+nu8$5D%4idIg8Mwk{5wA8VR-<#4R0i zBO#B3X666@76N9+C7P3RV#5XwNZ`e^HwO`LW}8MJtQ}J84dEhd^F}JlZVbq>^tj-~ zB{p##*l=$LxmLip9i#esvhg1+n{|Yebte%l&G48B_m06%qqI+W!UvQcA_jIb#UaLI z%0jDh;_*VL&<@f4N?*8!#2KRidk#XyjN2p_j-^v}Sfml;V;II0I-o4)K`DjxmC=62 zdqXs)ujbJxucF5SJh_NK8mDfmOBm9r`x$RhhNgJiNaU)E5BMw8xd7XU zU?GOK-v;SfOf5199N^adOSS!Gqg(;N2#XSR2<7@3`yGXf|1%G3;|P~tM?F>m4m-jc%m2o{o^-aFPmg@_Pvy~rA=8l4tYdHqyl z(r>7S^y(7HJTIZ(M%CAWNLNH@@~1~`%0TrX%*o8eWT(54G*My6Fy?!fLS%w%aJLPqAk|eA}y>x;pa>Ej2TXZO<*C0i#1%pn~Cxg4jLJz zAVGs*AU&GGKU$jmzuzk$sZ&74KkBK}2+XU)2&EA0H<`A>j_(+h%PM3J;un;zd1&R2 zX2%`D6U2Sho~CMNhMCLE;C=VVd?5{t7xNK;pL{hCUeRWohzO-FQ09%y&Wm`2V9LVa zj0eA~r!zVXbLC}J{UR^de5boecvrJshr#!nBRg=h&-g5+5K8J;e5!6`AfMbxyf0Ot zAc$^wgfSZlbHa!X1oPHY0P=fkr8VV@_NryP+Ym}Pl(PZ@(5I=IW@u&x9xWTz8vrvi zB^fRT%s&PZMgIU}-hqS$ndN7-AG`pv{Nv`pUR{%%bU}Xg3=YOoK>!oNn4^9SFyIBq zvW!?NsLYvhLw;Meim=d#r0g6*8D%vF3oZWu;$5j7Rxo9N2O2aMFy@nsj5An4nnT~> z-fPl!Lzjl7E>=Y13T*(hIjg0RfuuBJ_?XL$ktWgkkcqB3vLE9&pT)VK#=q z3E>9FJ|0*x1}Q#}-A-bC=HdyE-0>F5CQp&4&sGGr79;HlWg_5=!5A_kZiN2;603fg zH|fM)Shv78K2aF|0BVbv>WRQNF%p`An;-iyy+$8E1W)Oq*>y87@+Kam#83pufVqT- z$h3~-vy{PY+~#|>Qhx&R&Uq*~00ebCBRdlqwS~~(`gU!dfRu0dHvVENF9HjK2%ZKd zlhQU$J<;b5XT=57f&hecj&XAI_d^TQKimKj)xqH3;3+hQMTaqiE*7@y0R&B4&2;M8 z{{RD1ki+btz>DiD(t?|ML>As{pxY7v0_4ML3;Umd%*dz1c;<<{mG8VEk35g~44;@U z=x^DY6Pm`lnUieLShu@3%kws44XLhg71ZXsUje@r8 z90UUWQzRKG@!78Z=PCCwecj!R1)S>VSlx9tRfndY$f8*mE!&fWSVSgL$jgN={{Tv% z1JL*AK?m=Y()UBiF*-|lj22QXm@%@O2*v3g4y9uTQ=u3Oy)48wU~VC~W0`{?fS&UH z_|#_%0A){VaWje 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 diff --git a/assets/images/logos/invidious.jpg b/assets/images/logos/invidious.jpg deleted file mode 100644 index 3a54ace14ad8def3535b1059735db1a245799f61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2502 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 diff --git a/assets/images/logos/jiosaavn.png b/assets/images/logos/jiosaavn.png deleted file mode 100644 index 4d2d46e4b079fa9d1a60ef10a3aab3dd96750b90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14045 zcmeHuX*iT^__sAmv{UvTM#+|hEQ69|tl6^+m5>;cu^W=4vX5lnvhR^~Y)K`_HiOAt z5i@AUGGiD^?T22PmnbyEfg zM#|nF`vLGjJtNaEz&{5c=vsR-FmRmM`(tEC%Q(%zpb!VUe%0JRz9|v66}95s-PTa;On55V;b|_bi%;>&3}p z;m`lnbekR()>yBo@x$t4CyA|#ZN-P44Cq@>*M>@z=eGjbN*m?t-i{{o>w#T|B`oL^th)S?3y+BRuLzt~&?t_NG(u-nrf4`#y zuCoK)`WJai5^6QDvS_133L^|6=JrJ+JHK5w&;T2Sw)}AIhi~4g9|DA#$*VakPc#(I zosKzqe#Ku^l;{rQ5no}S)P|x%B#0p}6WI8j+~xRD^n{iWnTu)iEK8s-xFrm2^pbKA zHqR^FRT~7ElqM2yo}X}Tk#rd&?k&ZG!QQd@2P*xc}@nug%;pd1hs|MgcF^We0dx^ zfDMFoeZ2M~6R3}6f8`ABXzkd9T8k%2UsI)A-Bz2~XC;{glq62a*ohHYVOoW5VQVi$ zTjRHln3SIDkhr>&bx7T<-#pP22(FHyb0aptYPQFIdEXY$N5Zlu-cyZWJZY7zr$2^D z$XZL!H$)fwRaU&+5z4H=dL$(CK89PEH8c_FT-pDq=-=PCD7P+~iV=m-u@~(Q#m8ES zww~iYL`#vrEBf!!WnqHR+7}?LlIxB-AvO?VF>qNYO#F|1m(`U%092YP>>80T2F^9} zMhp8GJm89nXWr9|liS6o7bCUOL@RVdiWo)Mk*5=azZ#`=6tWoUie?pd*S9rkOYJ0hEKwwk9JIwPWrF#$Iv4a0Y0<0%hTRgTv-r9bK-+qyWKqS`b1org&vq{$5Hj?-dLa;`s)U|!~rn-D-( z=#YRXnV~~oC8c-p6tG6nKg@lS}iTGkz*q)PW6$c~M8uipPD zW3r;fqZDW4YWDq5|OBy;nSnjgEG&GglvBcvV z`LX)OhJ3r{wS9LB*}H*w@{r{7PVv=m=!I@Nb>f66-90o|x8JXpkh@+%ciU3oGZ9?Qdgw@$&Sant?fpDAHKdRT*Tx zzj5c#vYE z2NDuy&-5Qjtz_Q4IXzC?f{L9=B9!5d;PN7~3gz4i=^^c^D@W&xM9f%}FHyZF0MQ?Mk!tclnsH*K$No_<_=5ckJ7^(`*|sI7}t+#$>~-=W|I# zZ5=TY_Z)Tk!eVP#@)GhyGo3J88*XmxRm};pt|nSBf;at3)no%=`31_dlib}jzNGvj z#jo@|G_F*d7IuBgL!FI!@%S3bm3~|Kt#j#5^zIPGT73dzH~GpFT{Bl4SA6BxuHWuC zU-H1bLQB2^%_v}8)OExkUkG&88SJK|)}#>|v00AQ)8dvK?BaDUt8O$fd(ne*n(3g^ zMs?Dqd;M&;zQ$trZ)fLU8e3j257(>jz45x2QT*&c0{U;PYB#_q7@jkiCm3@W4?bR9 z@Y@-+md7E3En34$Yg}w&Sumo865@F1nlUzlDrcCCM*K-~4|eox-&KppA*b63B`qWnuYVVoIvVlzN<&S!`&|3y9%AY_sy)TOD zKnjemZn|<%CR25a??uzPx{Qi(;=?i4r5Cmy{z}*iQ&Za%p1d@FC5h)`nl!|=E#5u+ zT`}19r)?p}=ci1pNX-Y;YKlHrzo>f~;;y;5+@8OmqmOkxQAF@tGq!}Aln_m8K0a4? zwsLkrwiMh|hWNH{aoP5^a8778s#a~}qDn=!O8IMTaszT4YZn-)meDD3A4%Izyj+(* zffOHI9=&VJqJ2HbQF2-i{%HCTGvm&^kwN8V-#~5kKOYM<&2Rm<((03gM;>%cv$ZT} zo>w}tFw&qEeIzXQ7%0!QVOA-XoyIeq-Z16Hay_9+#Oj;UiFldCHkVhHnBxmpqT`|$ zL6*vE>OeAvY|TY?cAc8rvV;1x`Q%aH0T-`LyEqgpa@)4Q@1ZL^|9XsY+7i^0Xi(uO zZ&Rvp+=QCT)2VSEKA8m0e}8mfm5{NTT98EVoj7sX>t(@H?#4@_3SoZW>S*J1>37CF z!~NJ{*RRgTA6*K+zIBh5uAA$2q@m`Ltro4=+I0$iWnAB45dV0yC{IB^9jZ^S1lv5k zV-fCgSbBXM!||o8B7G%GhCE2~qdc(zf-o7`d03ZC78^gm%*CSo)Vor0$K&gTilP2F z{>AZt@$7WWyHerqA{t?9Yam|?jzc~pUfMRzxz5Kr)$zrMFBKGQj4*Xsc%;yULx+5N zGG=rp`+QosN>Ykxh8aJ{sc)r(bO>9)&R8o3wQ#zcKs4Qc&`M;Aw`E7RK$`gH_;Eh|2oLL| zHhGce@YFx@P9B!z`Q%Dfp54Cq=7wESY=!V5hg;D|UUT{L(A>4DgdWv9(;*3|tl zxNu>Rr^vc4$}ZtJ;&iOYz*%eGxlNuzDT?`4Z=Toz$and{}QJ)aMUak&XL{8_A>NMTpZc$DA6>zpnvf@t;Ha&97% zzt>zqHIAg67~OxA^ky%inC20cTOQohig{&jtN{O1+*ig?Dyos#BX4D#>DhC>Rd%%S z$N4~>hRnO*5Ozsyk-2+vVfziuR`C?DvrKwhb^p>W!*p#rJF>yLddNxJRcngnR{`f+ z(AD0a?UZ>UPJcpVy2TYnDxN)_>}`Pm5z)i9>XJ#5J+W{Z|JdxC``gBc=-8|qZkWR| zPpsSKq<4Q#I(?M;^iv~D`g66D%)n_E5eTs=q zf?uw;$7FQN)bymKP;qMEQ&n{VneG?$F_dEjQq7&-g}z<;kA^%6g{ANBWLn7eBF~6d z-(C09d%?tr8j4N)R(@|>S162B8D+rpJtDRvWT)EaE%6d|-I#h62g%Pb9Y`}|F?)Cw z9@5%&ChQb~k0>%A@HZ7$ueh6_D<>PxY4g^_**AuBTp_;-d)_f}T}aOnxv&-@?q^*S z(<#e>NiQE;YYMt|<4+LEJh^H!vVPqsUHTk^5jH>HaqrFNj+h5(rYe)jgZ6JqV?^Xh zb(XqVS>TbKCz|~T6h%QCqStNhXDdCxNABS+r%yz9B5k)TzcAxZ5G*VS!Jj8GFo{ef zGHqk!FXwoA*c5VDsulF2f((=8N^iLq#FmvR0--pIksV2!CFTB58ymq_ za9BYrvOOFSn3sM^Dv>T0&Fi0AQ8~tn?)HUjWA!Q0A32QbeUh6t7aPX8@d;JCTECf5+KX2P z^dmzxs2>XodzQbN1*K6tD%kQnezn+~HI6WI_wj6W-INP;M&NE)+5CjNwnbw+7&)Ni>bmd<)@fmA>~@ zJ&ksEXKJstFiBKN|9Y8wep=fRz!0Q~99rA%vykByOJ=Y6qUeD+fORxG)exChJ6@2W zm|MThKyjNy4GMiD<|o*UpBvp;v%-u~5`GyIdaA7+`j$B`^BV9xTH4!CrL&#CA-6%F zr#w)3F?V-r;ef^5=UpOaz@_F^w+9*yQLTl`FV0cNrhIX~X459pTn{=NBY52QoWJb8 zxY!q1oXmZ4A%9`wwD~a4uU!^m`|qh-+;p%(`&3ikNZss=R{9=j^=d+`OpYy;ooc6q z@wS1H+BC_hm+@6$9Dh z^&7rBW~F?n=JlHf2Lfx~Y6;owDoXvnFK5XdAIFXq*7Sn?ap~-_nZuFpp0iXut@7L5 zca%kh)>;H91VJPZzSF8rIx;KAByc*ClzSERu-E(knPs&{V%*$rpy`>%8-C2FDJ`E~ zYqp&l;8AEP58*OrytHR>aYQEf+SYa*tBN3#a4#~Cxs>ekw^j|t-tM35C}Fky{_-Fb z^~pA6HyV;%pP3)L67lJGBJdQVitW_$Hs&1PH#{oPHA0VRl*p^h#)j-(a6x58eZLa& z9GDH4|Lll>8wJ0P$g*E6{xVjc-RAq?m0*J>nLMbo?{QAQLSpX-2?Dgx(r-&cW-nVsb!6ujPPR?^p z-1A;;9Bbpf0Gh{6aMwn?ycE|-!W+q@Mba=dF!BJkyRQFj--PN|izbMAwV*Gt_*0E2 zsE_q785ulrA6n|{4-%`ngPk&I@vwMhKf)h2XjCK02A*UX)dU8?lll~Sgmd9(Z_g*6 zm^_IcPGpt#ydok5exzQuNm=Lb%~V9W4|PE*6ycF&g6`v`MYN@_8}mDYxiXdIXK3=I ziKeRn)lk{J^F4yvt#CvKB%OdhB%}1!$ehy|Z!JFb;dJtK{`60_Gp1jn%+l$p$_FYJ=RzXL72_J&T^6@9_LraO9dY{n zt#a3Xvc}O+TYb?gC-T!u$gV`SRb5L^L*(!~ky@WFaOB%`FRqP`vGQkP?(tX*o&T;0 zO+7&O^g|4gZSM$LF~<#z2!Q%ojQfzE{?F0k+wgE^y4Ha_%E1V&G>e6ul|mh>!ap7% z0W?9BBW~l>&kjXYcRkmhXV9aFOu~~REtV>B-)4dn*m`=_@i*)WK|pogF`y0P>^eF^O}UB07GB9~jYM!`|wP8rlC;q~qwRo4=-_vBu?k zJ6>Bm73tImTMEV`4u`>d0WI)o2#)nTqM@+q2_jb~EX363e%!b%8yyMVs-5Q;tIuOc z`e}jq&`?1l4kQMpneA>f9btmz4CxRQJ?slD>wSB9Y7si1dj2`;8r$UPvT$y^Z%ufB z=cTbZ!lXMTJ1DTp_`g<^6)M*Z&wnUo3W~ zu~l6yzv|PKT8X&pXapbEKLeA#=|raG`Y?YxKeFEMd&4gtzxT?H388-wcMO^1Q5m_1 z@|LpcBa{@fShdQY7JSXo5 z!XiNUPnZ5^tb#`H#$LDtXNYdL#x@x8gf;BsLJDWf7P_`4uFkMiss$U`0ZtpL=Zy%K zE)lONWSrYLRE0BJ8@ns>&rz5^f$ElQ(b{7Aj$ zaExY@h0TpC3-erGmJ@7R;c+IE#?mxNRoQEA9FYs) z+PC>KDV40AK{<%Ul&~ptn@Z4n(PxMirpm3(vt7TvVAIp9&Nld-LU2=Zg~yQ2{d6SI zSLv7K%#9oN&`{m(UP&wFFfQ@xp^pn1GKX0%x3}0Nv1$c+lRbPmIa8!v&Ex7kt;hr4 zi{mCaPTnwsH7QVh3xX#fU0#qm6zVbldRWQ7Z*!vNLZ*YzP*a`U&<#5U(&A1NB8tu8 z*U499VH%O5JwnYwmL_@G{YsWN{o_S1IK?X!*GVH$nNoqy{YFq+wcNazF4&Fm9NG(@ z$ps)9dz%Ey93c~Hm7jEKhrivQdY=FrA9_h`&JCf|p?rNl=f!7VU|H}6M2@lgH?+#1 zOu_G7Dx%h7VIt7(E|NML8AG&bvE)BLAmtq!r1R_)(9$XSJ&j9<^_w^V0Nre z^F(M}w51z%ygqdsl*@gWWNv5(l?bQ}%%DRsC<_~E0OEE)U1i!OV^GECzkf$?o0X#) zbOC3D%=Of9Vuq27pE+5S*R7>olk=;2H0Bf9E}x4A*MbZu*5wD*RAyJ}((2u@o}dI< zwGXfgt+FTH$0g*cdQ8!H&z{OXSeK^yervwH-cxR!wEg*u%}&oyfH*=+MG&ePzSk4F zr*k#vKdpN_fYWZuh6UK^8JU3lXu%( zC^6fQU84tdf+luPFz2m_TsTLp3^=#Y#!99_ze0%V>91TJdDl2sKky_Xc!UR5xWH)Y zog4E?W98@@^YE1wY5=(+t9-go#U#}3&GjOuv%%{Df&Bz>m&;4@LS!ZrDfdmPH7ux zC58Mi2L*Zk5}ov2UctERb}Kty%RGnY@5NS*m?VE2K9yX0K7!WU6}{THldLjw*UmwC zAxfXRld7}uLvu=T?ykBF;`bsbkdvK%hgc&WYI>Q_0H_M9YBp?xFrKI_tfmP3R~DvOa{7c;DQ_}8aBN#kWYexCicEiX^99{LNKZ{@lW z9@A6X4E_1~K($kb*QpD7qwYrg`g#!r`H4N`u?luy9{vRX6Rp;)R%+N91Z^dy%9~L) za*K#>&F0HQzZ-k=y77&aIDY+Eb^iQNq;|?=NYEO_t3CE~FPInw&c@S-V1B{p(`?;7 zQM*ZP`SwYHxk9hj;=2?8Ue79Z%w53wY~+HDFWYV_V_xU+t5YWz{utO6vvFsYf&x*s zwO+LKn-ZOD#rZKWZb1E_YuW{lDS<-N%GsT%B8Z)jR>2~Si(qUonv@?tyd3fF)X6a#G{W>U4twC*}$9`(WU?F20VaB~_!=d0^PHQr>wUR_E-a4_b}?vNXM63F z$+|+ey{YWa(FMQX6BqlwOqH}Xnzt)&`0s2j<)KD@NLla&H6wqG&EuxRh2!j!-Sgh5 z93`CY?gv0q(Rdnufm_JMPo^>>pN%K0^hoR*b4hrWPhH%GOCI#$pL_5J-!kV$sK`LN z$7XbzIM4x((k3ZQ3=4|76jx2IBPIyu)WPVM!7M;56$mlsR4PQ31!vYgt*d6I@o0=~LLIH-#sUj<7(W4zO z8>0L1`YFz}zTFQC2UsHmmV}Z|x}FKm>6fU&3h_t}Mo>H3^ZE-ba~a_n`kB^hzXkwV zJNu-6*Z=}C0ptI(J^j{#(OuLkKKqf*-{Zlp{b%IOD3Pq^%0i;g5JjxVaY{c`esq6y z0vJiWpDJ^)#D!r_IqoA{oAWBZuHMrdugIBZDw;HzD_U&H7BP#;_5^vPcwV{_RjdoskW)2+(v`;Oh5Eo;b!Al)Qn;F;G4f^z4qH@izIfMkV_i3{H_Zj86 zvDe2ZWy#lj^&Be#3%lNt$Q_5Z=Zg&7hfr(D`9ABI?AMu6hpPOspYm?#tajezW6=h4 zd<%!9*{{yr?QYMB=;8j-;mFLP^;9%}iSZdH$CRCqs_bu&NA4)KOBXxzDcL6-SiddQ zN4B|xc>oZOwG}d?iy|?&{s@fcbzvYJCm@@^p@>bfaQgD$ty6$hEt{s#Xwp9w;`eSc>`CT&(AlIqy1#1IB)VlZ zLDbltI9?W48LaoG)h*AHcJrov1*kH7zkXlh=Rb`<5w0_C#+S(^ibBx;K?Trr&rJ8| zS5HYtSgDqbr{hzlA>#_-gL98w9wq2n|E+^#!7(db?%lw9*p-G_(_VwFKe(w24DIQ3 zik4%Dbmp=3Fu`%?`ti9(`FrE^?oUc*rn~g_>YyXip?8L2d?bC1YWAm{uWp{-uE~!y zz~6qKJyuUB{}s2`#1(3J1P-Q*nGQVsR5=y890#SZBg~{BE@_HuP2c;LwN2Np_B~)a zdvEgtnW>oS_DD2>Q};|*tbv_7hQUmMN*dNG%NlG@kTQ$wnlIJIHn<4NF3ib}(=P>O z(%rj^IAd!%bOFHdM`f`%gMAI;96Zreb#BouR`t6(pXMAGXacTM0}S&XDNqQSUI&G$ z;jQUlJL9Dl-*bIvjj-LdocJ#shaz{@=61g_?GR%V6t_5Z>AuU)WV5+8DtvN`t*Sy{ ze_H005dF*TxZ_~1csd@8y;3o7w0h)=`R6gMa7-{&9XX8QfpX?3ft39z`p0xpgR{RR zL0$VGMaEElh`=ZT%78sY*UUcjIxPTs$WAZ_{=A+wKSo1G`)>teUAXv}^++vZP`pb0 zwU7tAvQ-fJv>mwD8<(gCmu0m8+O2lWxHpI;Fkr+L@y-{b469Vzf|~pM*JM%NxJ$o% z*WjCBeR}m$!U!;~=jMoL(52T9l^1#*78k+ z0gzd?3Yrh!dG0sw-BY@kAPanA09?HjTj0CaQ|3e7rnR|I$jN6vQg3c?O^&sEYJh}t zr~JfQ&jVi2ttYWw`o7nb(<()H_UaqMbO*dw6|@|TAYQFAO0XUx5Wb8x>^7-{`%$($ zzhFT-K7J&`*z$b~U}&h1w&|Ud3c3#J1^(M=HTpY;yc;2kMs^xgfSKcWATJ>bf*L?d zH5I$v(`b;d8U`Z}pQR_t@QI81E+YnJAK80w>-d^!$91Hl_2|HOLFJBb!&cm=j++fE zzy`0|rJ-7Rj($3rNbO#8)Lkr6D)ovrB+O6!;@)gT#;3l8+7D(Pyw8r5+)L@_a``j38I3)Zd1Lq@ ziGJjAeq$E--k^}J;$p{o#Tmm1Z}a5ihy%+kVO;$n0gym<^*Z0m5RX2B0YFmMNH5)S z(b?kU$%Wa4?!B%oHuXl)?nZppw^mK{vHG6^azkuCy`Tg27A|-rW1^r8vSp`7Lo3X$ zx3#HV$9TzgBoPdSx}m+2NR^Pa?*1H+Td{MmbY>43tl^Yj%H(%ywJNgX)}&1aS9rTLxn%ew8urf z|MRjUeV_NI`--{sCRPtNXT0@JWpBjTJ)CHf^T>uRaCl-m56;|r|2G*o@WyuFo#rB> z7rbh{#rb@n(OGkbfCeKuUQxs;Z+Qk7Z~SEtn_PT!1OUJ54VOS0%-8N*N+t8>W9ROw zeEk7}Oxufy_cD6)6rwUir?hcB>pMjj6`&6fQCx-ZTOds3ZP!3y{S}S5LwG z(xsU`*EA<>keJ&XO*1rWpyDKrj(hnU*gUk6ybXK2KxiT|Q1YY#=8qIe_ki1(!i6MO z{BoDkRet|K{86@j0@7U}xhUqz{(&E!9e{|Rj(I6w4P8Zb#GdR2(BQzpq zyJO>SL~^owpVrCgEWXQj#o;+ERzuAO@}6Jmkw&CfcNaM-Ddo)J*+i?CDGWL(omT+c z@T`)^#!biwiD~n@r=Fx?&-2kGhOpR-1iQCEm~SRrO=J&lndEr9KJY zwgj3MRnXFxTABGD2#@5dh$fHZ7X7nFTYRo56|b-MO4a?m=fC8@HrBs4aTJA0lzav> z$gxMq=Tqk=j}?O$@;yQ2p-l~QrkHCh(dK(O6ucic~?ip8XFFN^bve{dGyuANZZr7Dj!}QqwoIZkOCSoTloW5 z$&+n3g`4y=L3V?{WJH>8LFrgaF!zAsRqr|2N9(G39dfQqGZNWCS3!zO9T$HgeqNYS<&w zafncM{xYUXXyhyK;|Z_=FcS|sxURy#WLdYcHsg!DN~YKyv@^hPwB6B2V7nos$g^U5 z^4B4~0g|-8;QA^*3PMe-IAA?g^O=9STUudru@&pt2Hb)01MvF+kGm0ipHj ze|+l%h+lg1RcsO<1KW5cP~;V!o;kCxt)m1-AQ}s_niR454)$IpFB+MdpABFsF5Jgi z<$ZeQ*@3@QYe=R9@f`X-XL9%xOPCc5qX;Of>C$2j`)0--meqFa^6?vY=9?BFnU1xm z{U(b({P}~wlY3FXGVQ;s2xZUCY^;f*986n(Jali+$MQfXI~}Pjx&P9wC;7Tfw(MYk zB#91?Q>hi7d7056qC`tr*_oK2|JdV5GEfNBL68j@2NWBuI8MPiTZ{v)t_ z&pY?H?wIy<4%*u!cAECvQ@_du^qy?&{PO|zF9#Yh;UUIBDllwUQi?3O=2^Z2EO4w2 z*Jtn_`Ft{CmVheFj|aT>DAA-dQeV^oslMz3yXpm3zPtzC|J|ows(WXaM^{-J>xxzr zF8OfR@C+f5!UKzg1)4n9A^g^sc(3w5^7;EI0QjnaTTj^>dk5B~pN6QBhAl#%=ZF?C zPhA^nS2qxX6l_j?`Zw^i1n%ldCrKoliV2)rmKGOweVxC+ zEM2FA^?-T7(vwo0+Dx!wFk1)$uuV%0O{4OCVNl7lJq~}(WbgBuNMnZo{ix=DCGh`^ d1hzxnzlhD9IdOCqd>F<6(=xhVdF|HY{{wt6oA3Yt diff --git a/assets/images/placeholder.png b/assets/images/placeholder.png deleted file mode 100644 index 6f6d451f2c07a7141944461ac691b96c065f61be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8886 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 C02ti> diff --git a/assets/images/user-placeholder.png b/assets/images/user-placeholder.png deleted file mode 100644 index cb2325ff296700d98d6249a0ba7e1e24958132b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48389 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